From 7816d2efa9c7b7670e8eab8f05911713f95156f7 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 31 Jan 2018 14:24:32 -0800 Subject: [PATCH 001/567] Implement view filters on the populate* request options (#260) * Implement view filters on the populate* request options --- tableauserverclient/__init__.py | 2 +- tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- tableauserverclient/server/request_options.py | 35 +++++++++++++++++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5840d7b6..30ec47981 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -4,7 +4,7 @@ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem -from .server import RequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ +from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions __version__ = get_versions()['version'] diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 12a640723..704fdb66a 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,5 +1,5 @@ from .request_factory import RequestFactory -from .request_options import ImageRequestOptions, PDFRequestOptions, RequestOptions +from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort from .. import ConnectionItem, DatasourceItem, JobItem, \ diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 0335ce781..62cd3af50 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -105,7 +105,7 @@ def csv_fetcher(): def _get_view_csv(self, view_item, req_options): url = "{0}/{1}/data".format(self.baseurl, view_item.id) - with closing(self.get_request(url, parameters={"stream": True})) as server_response: + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: csv = server_response.iter_content(1024) return csv diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 37f23f54c..b7d5c591d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -62,12 +62,37 @@ def apply_query_params(self, url): return "{0}?{1}".format(url, '&'.join(params)) -class ImageRequestOptions(RequestOptionsBase): +class _FilterOptionsBase(RequestOptionsBase): + """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): + self.view_filters = [] + + def apply_query_params(self, url): + raise NotImplementedError() + + def vf(self, name, value): + self.view_filters.append((name, value)) + return self + + def _append_view_filters(self, params): + for name, value in self.view_filters: + params.append('vf_{}={}'.format(name, value)) + + +class CSVRequestOptions(_FilterOptionsBase): + def apply_query_params(self, url): + params = [] + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) + + +class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = 'high' def __init__(self, imageresolution=None): + super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution def apply_query_params(self, url): @@ -75,11 +100,12 @@ def apply_query_params(self, url): if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) -class PDFRequestOptions(RequestOptionsBase): - # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -100,6 +126,7 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None): + super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation @@ -111,4 +138,6 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + self._append_view_filters(params) + return "{0}?{1}".format(url, '&'.join(params)) From 2a2c898771d1acb9ee186aa4f6856b0ebff6c9db Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 16 Feb 2018 09:43:36 -0800 Subject: [PATCH 002/567] Export sample (#263) * Adding export sample/tool * Removing unused import * Addressing code review feedback * Missed one description --- samples/export.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++ samples/list.py | 7 +++-- 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 samples/export.py diff --git a/samples/export.py b/samples/export.py new file mode 100644 index 000000000..67b3319a8 --- /dev/null +++ b/samples/export.py @@ -0,0 +1,74 @@ +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Export a view as an image, pdf, or csv') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', default=None) + parser.add_argument('-p', default=None) + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', + 'pdf')) + group.add_argument('--png', dest='type', action='store_const', const=('populate_image', 'ImageRequestOptions', + 'image', 'png')) + group.add_argument('--csv', dest='type', action='store_const', const=('populate_csv', 'CSVRequestOptions', 'csv', + 'csv')) + + 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') + parser.add_argument('resource_id', help='LUID for the view') + + args = parser.parse_args() + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + views = filter(lambda x: x.id == args.resource_id, + TSC.Pager(server.views.get)) + view = views.pop() + + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + (populate_func_name, option_factory_name, member_name, extension) = args.type + populate = getattr(server.views, populate_func_name) + option_factory = getattr(TSC, option_factory_name) + + if args.filter: + options = option_factory().vf(*args.filter.split(':')) + else: + options = None + if args.file: + filename = args.file + else: + filename = 'out.{}'.format(extension) + + populate(view, options) + with file(filename, 'wb') as f: + if member_name == 'csv': + f.writelines(getattr(view, member_name)) + else: + f.write(getattr(view, member_name)) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index ec2ff9a6b..b53795d1a 100644 --- a/samples/list.py +++ b/samples/list.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + 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('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) @@ -21,7 +21,7 @@ def main(): 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']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'view']) args = parser.parse_args() @@ -40,7 +40,8 @@ def main(): with server.auth.sign_in(tableau_auth): endpoint = { 'workbook': server.workbooks, - 'datasource': server.datasources + 'datasource': server.datasources, + 'view': server.views }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): From 4e8bb798b40a19432febe43343f0b2ebba8d6a99 Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 21 Feb 2018 20:45:05 +0300 Subject: [PATCH 003/567] Change to correct parent project id tag name (#267) * Change to correct parent project id tag name * Change to correct parent project id tag name --- tableauserverclient/models/project_item.py | 2 +- test/assets/project_create.xml | 2 +- test/assets/project_get.xml | 2 +- test/assets/project_update.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 91b82ef0e..92e0282ae 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -80,6 +80,6 @@ def _parse_element(project_xml): name = project_xml.get('name', None) description = project_xml.get('description', None) content_permissions = project_xml.get('contentPermissions', None) - parent_id = project_xml.get('parentId', None) + parent_id = project_xml.get('parentProjectId', None) return id, name, description, content_permissions, parent_id diff --git a/test/assets/project_create.xml b/test/assets/project_create.xml index ebfada762..5cd29d954 100644 --- a/test/assets/project_create.xml +++ b/test/assets/project_create.xml @@ -1,4 +1,4 @@ - + diff --git a/test/assets/project_get.xml b/test/assets/project_get.xml index 12133f432..777412b30 100644 --- a/test/assets/project_get.xml +++ b/test/assets/project_get.xml @@ -4,6 +4,6 @@ - + diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index 0307e7c18..eaa884627 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,4 @@ - + From 86e463810be80c2b562845f7c14b775d604f2a86 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Wed, 21 Feb 2018 11:58:16 -0800 Subject: [PATCH 004/567] add export_wb sample for 'fullpdf' (#264) * add export_wb sample for 'fullpdf' * Addressing code review feedback from Lee * Fixing the dumb thing that I did where I was getting the view, then throwing it away and getting it again... * pep8 error --- samples/export_wb.py | 92 ++++++++++++++++++++++++++++++++++++++++++++ samples/list.py | 8 ++-- 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 samples/export_wb.py diff --git a/samples/export_wb.py b/samples/export_wb.py new file mode 100644 index 000000000..8d3640ab4 --- /dev/null +++ b/samples/export_wb.py @@ -0,0 +1,92 @@ +# +# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a +# workbook. +# +# You will need to do `pip install PyPDF2` to use this sample. +# + +import argparse +import getpass +import logging +import tempfile +import shutil +import functools +import os.path + +import tableauserverclient as TSC +try: + import PyPDF2 +except ImportError: + print('Please `pip install PyPDF2` to use this sample') + import sys + sys.exit(1) + + +def get_views_for_workbook(server, workbook_id): # -> Iterable of views + workbook = server.workbooks.get_by_id(workbook_id) + server.workbooks.populate_views(workbook) + return workbook.views + + +def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf + logging.info("Exporting {}".format(view.id)) + destination_filename = os.path.join(tempdir, view.id) + server.views.populate_pdf(view) + with file(destination_filename, 'wb') as f: + f.write(view.pdf) + + return destination_filename + + +def combine_into(dest_pdf, filename): # -> None + dest_pdf.append(filename) + return dest_pdf + + +def cleanup(tempdir): + shutil.rmtree(tempdir) + + +def main(): + parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook') + 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') + parser.add_argument('resource_id', help='LUID for the workbook') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tempdir = tempfile.mkdtemp('tsc') + logging.debug("Saving to tempdir: %s", tempdir) + + tableau_auth = TSC.TableauAuth(args.username, password, args.site) + server = TSC.Server(args.server, use_server_version=True) + try: + with server.auth.sign_in(tableau_auth): + get_list = functools.partial(get_views_for_workbook, server) + download = functools.partial(download_pdf, server, tempdir) + + downloaded = (download(x) for x in get_list(args.resource_id)) + output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) + with file(args.file, 'wb') as f: + output.write(f) + finally: + cleanup(tempdir) + + +if __name__ == '__main__': + main() diff --git a/samples/list.py b/samples/list.py index b53795d1a..d1a25f08d 100644 --- a/samples/list.py +++ b/samples/list.py @@ -14,9 +14,9 @@ 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('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='password for the user') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -25,10 +25,10 @@ def main(): args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) From 6586931bce491622d9c14715f8f94324f9883c53 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 9 Mar 2018 15:32:37 -0800 Subject: [PATCH 005/567] Add a simple static method to strip non-XML responses from debug log entries. Tested manually, and a simple regression test added to the suite. --- samples/download_view_image.py | 2 +- tableauserverclient/server/endpoint/endpoint.py | 13 ++++++++++++- test/test_regression_tests.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 2da232061..b95a8628b 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -43,7 +43,7 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_id) server = TSC.Server(args.server) # The new endpoint was introduced in Version 2.5 - server.version = 2.5 + server.version = "2.5" with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index deaa94a30..e78b2e0cd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -27,6 +27,17 @@ def _make_common_headers(auth_token, content_type): return headers + @staticmethod + def _safe_to_log(server_response): + '''Checks if the server_response content is not xml (eg binary image or zip) + and and replaces it with a constant + ''' + ALLOWED_CONTENT_TYPES = ('application/xml',) + if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: + return '[Truncated File Contents]' + else: + return server_response.content + def _make_request(self, method, url, content=None, request_object=None, auth_token=None, content_type=None, parameters=None): if request_object is not None: @@ -50,7 +61,7 @@ def _make_request(self, method, url, content=None, request_object=None, return server_response def _check_status(self, server_response): - logger.debug(server_response.content) + logger.debug(self._safe_to_log(server_response)) if server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 95bdceacb..8958c3cf8 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,8 +1,23 @@ import unittest import tableauserverclient.server.request_factory as factory +from tableauserverclient.server.endpoint import Endpoint class BugFix257(unittest.TestCase): def test_empty_request_works(self): result = factory.EmptyRequest().empty_req() self.assertEqual(b'', result) + + +class BugFix273(unittest.TestCase): + def test_binary_log_truncated(self): + + class FakeResponse(object): + + headers = {'Content-Type': 'application/octet-stream'} + content = b'\x1337' * 1000 + status_code = 200 + + server_response = FakeResponse() + + self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') From ad1be7d54ab5ccee02aa00942734e338477fb978 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 6 Apr 2018 09:57:02 -0700 Subject: [PATCH 006/567] Fix update datasource connection server port (#283) * addressing bug #281: update datasource connection not updating server port properly --- tableauserverclient/server/request_factory.py | 2 +- test/assets/datasource_connection_update.xml | 2 +- test/test_datasource.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c4f10d731..b09ecc3ce 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -365,7 +365,7 @@ def update_req(self, xml_request, connection_item): if connection_item.server_address: connection_element.attrib['serverAddress'] = connection_item.server_address.lower() if connection_item.server_port: - connection_element.attrib['port'] = str(connection_item.server_port) + connection_element.attrib['serverPort'] = str(connection_item.server_port) if connection_item.username: connection_element.attrib['userName'] = connection_item.username if connection_item.password: diff --git a/test/assets/datasource_connection_update.xml b/test/assets/datasource_connection_update.xml index 0e4d21ed0..5b84616dd 100644 --- a/test/assets/datasource_connection_update.xml +++ b/test/assets/datasource_connection_update.xml @@ -2,4 +2,4 @@ \ No newline at end of file + type="textscan" serverAddress="bar" serverPort="9876" userName="foo"/> \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index ff1546d62..9de8ae375 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -161,10 +161,14 @@ def test_update_connection(self): self.server.datasources.populate_connections(single_datasource) connection = single_datasource.connections[0] + connection.server_address = 'bar' + connection.server_port = '9876' connection.username = 'foo' 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('foo', new_connection.username) def test_publish(self): From df44535e81e32a22d296244d4c451dd4535faf50 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 10 Apr 2018 16:50:59 -0700 Subject: [PATCH 007/567] Add ability to rename workbook using the 'update workbook' endpoint (#284) * adding ability to rename workbook using workbooks.update() * updating test --- tableauserverclient/server/request_factory.py | 2 ++ test/assets/workbook_update.xml | 2 +- test/test_workbook.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b09ecc3ce..241f47985 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -334,6 +334,8 @@ def _generate_xml(self, workbook_item, connection_credentials=None): def update_req(self, workbook_item): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') + if workbook_item.name: + workbook_element.attrib['name'] = workbook_item.name if workbook_item.show_tabs: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 9c9674700..2470347a8 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_workbook.py b/test/test_workbook.py index 8c36f0229..de8f8fbaf 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -114,12 +114,14 @@ def test_update(self): single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74', show_tabs=True) single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_workbook.name = 'renamedWorkbook' single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) self.assertEqual(True, single_workbook.show_tabs) self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) + self.assertEqual('renamedWorkbook', single_workbook.name) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 728ec5c1c8af4c8425c3b48f094560cf7b1f958b Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 11 Apr 2018 13:18:21 -0700 Subject: [PATCH 008/567] adding project id field to view_item (#285) --- tableauserverclient/models/view_item.py | 12 +++++++++++- test/assets/view_get.xml | 2 ++ test/test_view.py | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a8c1a6988..1fc6d4e8e 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -11,6 +11,7 @@ def __init__(self): self._name = None self._owner_id = None self._preview_image = None + self._project_id = None self._pdf = None self._csv = None self._total_views = None @@ -59,6 +60,10 @@ def preview_image(self): raise UnpopulatedPropertyError(error) return self._preview_image() + @property + def project_id(self): + return self._project_id + @property def pdf(self): if self._pdf is None: @@ -97,6 +102,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): usage_elem = view_xml.find('.//t:usage', namespaces=ns) workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) owner_elem = view_xml.find('.//t:owner', namespaces=ns) + project_elem = view_xml.find('.//t:project', namespaces=ns) view_item._id = view_xml.get('id', None) view_item._name = view_xml.get('name', None) view_item._content_url = view_xml.get('contentUrl', None) @@ -107,10 +113,14 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): if owner_elem is not None: view_item._owner_id = owner_elem.get('id', None) - all_view_items.append(view_item) + + if project_elem is not None: + view_item._project_id = project_elem.get('id', None) if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: view_item._workbook_id = workbook_elem.get('id', None) + + all_view_items.append(view_item) return all_view_items diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index c8e0601bd..36f43e255 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -5,10 +5,12 @@ + + \ No newline at end of file diff --git a/test/test_view.py b/test/test_view.py index 09ce2f3d7..292f86887 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -39,12 +39,14 @@ def test_get(self): self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', all_views[0].content_url) self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual('Overview', all_views[1].name) self.assertEqual('Superstore/sheets/Overview', all_views[1].content_url) self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id) + self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) def test_get_with_usage(self): with open(GET_XML_USAGE, 'rb') as f: From 844e0b1248d91cceffc595b90c16cca4b274b190 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Mon, 16 Apr 2018 11:54:10 -0700 Subject: [PATCH 009/567] adding more fields for filtering ability (#286) --- tableauserverclient/server/request_options.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index b7d5c591d..be00e8975 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -14,12 +14,22 @@ class Operator: class Field: CreatedAt = 'createdAt' + DomainName = 'domainName' + DomainNickname = 'domainNickname' + HitsTotal = 'hitsTotal' + IsLocal = 'isLocal' LastLogin = 'lastLogin' + MinimumSiteRole = 'minimumSiteRole' Name = 'name' + OwnerDomain = 'ownerDomain' + OwnerEmail = 'ownerEmail' OwnerName = 'ownerName' + ProjectName = 'projectName' SiteRole = 'siteRole' Tags = 'tags' + Type = 'type' UpdatedAt = 'updatedAt' + UserCount = 'userCount' class Direction: Desc = 'desc' From d28d5fc19458b9fca11b2534033f9fb332496886 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 17 Apr 2018 09:16:43 -0700 Subject: [PATCH 010/567] Refactor the refresh sample to be more explicit (#288) --- samples/refresh.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/samples/refresh.py b/samples/refresh.py index dd39bc6f6..73aa7fb2f 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -16,7 +16,7 @@ def main(): parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) + parser.add_argument('--password', '-p', default=None, help='if not specified, you will be prompted') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -26,10 +26,10 @@ def main(): args = parser.parse_args() - if args.p is None: + if args.password is None: password = getpass.getpass("Password: ") else: - password = args.p + password = args.password # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) @@ -39,15 +39,21 @@ def main(): tableau_auth = TSC.TableauAuth(args.username, password, args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources - }.get(args.resource_type) + if args.resource_type == "workbook": + # Get the workbook by its Id to make sure it exists + resource = server.workbooks.get_by_id(args.resource_id) - refresh_func = endpoint.refresh - resource = endpoint.get_by_id(args.resource_id) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.workbooks.refresh(resource) + else: + # Get the datasource by its Id to make sure it exists + resource = server.datasources.get_by_id(args.resource_id) - print(refresh_func(resource)) + # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done + results = server.datasources.refresh(resource) + + print(results) + # TODO: Add a flag that will poll and wait for the returned job to be done if __name__ == '__main__': From 18372ba934e3ed4fe67455f552dd2dab41355724 Mon Sep 17 00:00:00 2001 From: Sergey Sotnichenko Date: Fri, 20 Apr 2018 00:48:05 +0300 Subject: [PATCH 011/567] 277 update group feature (#279) * Adding update method for groups * Add some docs * Add test for update group function --- docs/docs/api-ref.md | 46 +++++++++++++++++++ .../server/endpoint/groups_endpoint.py | 14 ++++++ tableauserverclient/server/request_factory.py | 11 +++++ test/assets/group_update.xml | 6 +++ test/test_group.py | 14 ++++++ 5 files changed, 91 insertions(+) create mode 100644 test/assets/group_update.xml diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 7b22c3517..81d1211dd 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -849,6 +849,52 @@ Error | Description

+#### groups.update + +```py +groups.update(group_item, default_site_role=UserItem.Roles.Unlicensed) +``` + +Updates the group on the site. +If domain_name = 'local' then update only the name of the group. +If not - update group from the Active Directory with domain_name. + +REST API: [Update Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Group%3FTocPath%3DAPI%2520Reference%7C_____95){:target="_blank"} + + +**Parameters** + +Name | Description +:--- | :--- +`group_item` | the group_item specifies the group to update. +`default_site_role` | if group updates from Active Directory then this is the default role for the new users. + + +**Exceptions** + +Error | Description +:--- | :--- +`Group item missing ID` | Raises an exception if a valid `group_item.id` is not provided. + + +**Example** + +```py +# Update a group + +# import tableauserverclient as TSC +# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') +# server = TSC.Server('http://SERVERURL') + + with server.auth.sign_in(tableau_auth): + all_groups, pagination_item = server.groups.get() + + for group in all_groups: + server.groups.update(group) +``` +
+
+ #### groups.get ```py diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index bee595b25..2428ff9be 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -7,6 +7,8 @@ logger = logging.getLogger('tableau.endpoint.groups') +UNLICENSED_USER = UserItem.Roles.Unlicensed + class Groups(Endpoint): @property @@ -55,6 +57,18 @@ def delete(self, group_id): self.delete_request(url) logger.info('Deleted single group (ID: {0})'.format(group_id)) + @api(version="2.0") + def update(self, group_item, default_site_role=UNLICENSED_USER): + if not group_item.id: + error = "Group item missing ID." + raise MissingRequiredFieldError(error) + url = "{0}/{1}".format(self.baseurl, group_item.id) + update_req = RequestFactory.Group.update_req(group_item, default_site_role) + server_response = self.put_request(url, update_req) + logger.info('Updated group item (ID: {0})'.format(group_item.id)) + updated_group = GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_group + # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 241f47985..277605fa7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -107,6 +107,17 @@ def create_req(self, group_item): group_element.attrib['name'] = group_item.name return ET.tostring(xml_request) + def update_req(self, group_item, default_site_role): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + if group_item.domain_name != 'local': + project_element = ET.SubElement(group_element, 'import') + project_element.attrib['source'] = "ActiveDirectory" + project_element.attrib['domainName'] = group_item.domain_name + project_element.attrib['siteRole'] = default_site_role + return ET.tostring(xml_request) + class PermissionRequest(object): def _add_capability(self, parent_element, capability_set, mode): diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml new file mode 100644 index 000000000..b5dba4bc6 --- /dev/null +++ b/test/assets/group_update.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 244ba47b8..7096ca408 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') +UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') class GroupTests(unittest.TestCase): @@ -183,3 +184,16 @@ def test_create_group(self): group = self.server.groups.create(group_to_create) self.assertEqual(group.name, u'試供品') self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + + def test_update(self): + with open(UPDATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.put(self.baseurl + '/ef8b19c0-43b6-11e6-af50-63f5805dbe3c', text=response_xml) + group = TSC.GroupItem(name='Test Group') + group._domain_name = 'local' + group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + group = self.server.groups.update(group) + + self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) + self.assertEqual('Group updated name', group.name) From 48df0ef1402ae6db19656104199f7aac54a65edf Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 20 Apr 2018 10:07:39 -0700 Subject: [PATCH 012/567] Multi-Credential Support in TSC (#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. --- samples/publish_workbook.py | 19 ++++- .../models/connection_credentials.py | 12 +++ tableauserverclient/models/connection_item.py | 31 ++++++++ .../server/endpoint/datasources_endpoint.py | 9 ++- .../server/endpoint/endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 18 ++++- tableauserverclient/server/request_factory.py | 79 +++++++++++++------ test/test_datasource.py | 47 +++++++++++ test/test_workbook.py | 50 ++++++++++++ 9 files changed, 234 insertions(+), 33 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 37d66d2dc..6798a2106 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -19,6 +19,7 @@ import logging import tableauserverclient as TSC +from tableauserverclient import ConnectionCredentials, ConnectionItem def main(): @@ -50,10 +51,26 @@ def main(): all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) + connection1 = ConnectionItem() + connection1.server_address = "mssql.test.com" + connection1.connection_credentials = ConnectionCredentials("test", "password", True) + + connection2 = ConnectionItem() + connection2.server_address = "postgres.test.com" + connection2.server_port = "5432" + connection2.connection_credentials = ConnectionCredentials("test", "password", True) + + all_connections = list() + all_connections.append(connection1) + all_connections.append(connection2) + # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true) + new_workbook = server.workbooks.publish(new_workbook, + args.filepath, + overwrite_true, + connections=all_connections) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index 8c3a77925..c883a515a 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -32,3 +32,15 @@ def oauth(self): @property_is_boolean def oauth(self, value): self._oauth = value + + @classmethod + def from_xml_element(cls, parsed_response, ns): + connection_creds_xml = parsed_response.find('.//t:connectionCredentials', namespaces=ns) + + name = connection_creds_xml.get('name', None) + password = connection_creds_xml.get('password', None) + embed = connection_creds_xml.get('embed', None) + oAuth = connection_creds_xml.get('oAuth', None) + + connection_creds = cls(name, password, embed, oAuth) + return connection_creds diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a52d32e9e..894cabe62 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from .connection_credentials import ConnectionCredentials class ConnectionItem(object): @@ -12,6 +13,7 @@ def __init__(self): self.server_address = None self.server_port = None self.username = None + self.connection_credentials = None @property def datasource_id(self): @@ -51,3 +53,32 @@ def from_response(cls, resp, ns): connection_item._datasource_name = datasource_elem.get('name', None) all_connection_items.append(connection_item) return all_connection_items + + @classmethod + def from_xml_element(cls, parsed_response, ns): + ''' + + + + + + + + + ''' + all_connection_items = list() + all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns) + + for connection_xml in all_connection_xml: + connection_item = cls() + + connection_item.server_address = connection_xml.get('serverAddress', None) + connection_item.server_port = connection_xml.get('serverPort', None) + + connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns) + + if connection_credentials is not None: + + connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + + return all_connection_items diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 03e261765..5e986f91c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,7 +151,8 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - def publish(self, datasource_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections="99.99") + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -180,7 +181,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item, - connection_credentials) + connection_credentials, + connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -188,7 +190,8 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) xml_request, content_type = RequestFactory.Datasource.publish_req(datasource_item, filename, file_contents, - connection_credentials) + connection_credentials, + connections) server_response = self.post_request(url, xml_request, content_type) new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index e78b2e0cd..a19c32acd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -32,7 +32,7 @@ def _safe_to_log(server_response): '''Checks if the server_response content is not xml (eg binary image or zip) and and replaces it with a constant ''' - ALLOWED_CONTENT_TYPES = ('application/xml',) + ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' else: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4ce9983f3..537e3ec81 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,7 +199,14 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") - def publish(self, workbook_item, file_path, mode, connection_credentials=None): + @parameter_added_in(connections='2.8') + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None): + + if connection_credentials is not None: + import warnings + warnings.warn("connection_credentials is being deprecated. Use connections instead", + DeprecationWarning) + if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -230,16 +237,21 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None): logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: file_contents = f.read() + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item, filename, file_contents, - connection_credentials) + connection_credentials=conn_creds, + connections=connections) + logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 277605fa7..d8f66264d 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -25,6 +25,25 @@ def wrapper(self, *args, **kwargs): return wrapper +def _add_connections_element(connections_element, connection): + connection_element = ET.SubElement(connections_element, 'connection') + connection_element.attrib['serverAddress'] = connection.server_address + if connection.server_port: + connection_element.attrib['serverPort'] = connection.server_port + if connection.connection_credentials: + connection_credentials = connection.connection_credentials + _add_credentials_element(connection_element, connection_credentials) + + +def _add_credentials_element(parent_element, connection_credentials): + credentials_element = ET.SubElement(parent_element, 'connectionCredentials') + credentials_element.attrib['name'] = connection_credentials.name + credentials_element.attrib['password'] = connection_credentials.password + credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + if connection_credentials.oauth: + credentials_element.attrib['oAuth'] = 'true' + + class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element('tsRequest') @@ -40,20 +59,23 @@ def signin_req(self, auth_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None): + def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') datasource_element = ET.SubElement(xml_request, 'datasource') datasource_element.attrib['name'] = datasource_item.name project_element = ET.SubElement(datasource_element, 'project') project_element.attrib['id'] = datasource_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(datasource_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(datasource_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(datasource_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, datasource_item): @@ -73,15 +95,15 @@ def update_req(self, datasource_item): return ET.tostring(xml_request) - def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) def publish_req_chunked(self, datasource_item, connection_credentials=None): - xml_request = self._generate_xml(datasource_item, connection_credentials) + xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -324,7 +346,7 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None): + def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -332,14 +354,17 @@ def _generate_xml(self, workbook_item, connection_credentials=None): workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() project_element = ET.SubElement(workbook_element, 'project') project_element.attrib['id'] = workbook_item.project_id - if connection_credentials: - credentials_element = ET.SubElement(workbook_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' - - if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + + if connection_credentials is not None and connections is not None: + raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + + if connection_credentials is not None: + _add_credentials_element(workbook_element, connection_credentials) + + if connections is not None: + connections_element = ET.SubElement(workbook_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -357,15 +382,19 @@ def update_req(self, workbook_item): owner_element.attrib['id'] = workbook_item.owner_id return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None): - xml_request = self._generate_xml(workbook_item, connection_credentials) + def publish_req_chunked(self, workbook_item, connections=None): + xml_request = self._generate_xml(workbook_item, + connection_credentials=connection_credentials, + connections=connections) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) diff --git a/test/test_datasource.py b/test/test_datasource.py index 9de8ae375..9ddf5a3c8 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,8 +1,10 @@ import unittest import os import requests_mock +import xml.etree.ElementTree as ET import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset ADD_TAGS_XML = 'datasource_add_tags.xml' @@ -245,3 +247,48 @@ def test_publish_invalid_file_type(self): new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleWB.twbx'), self.server.PublishMode.Append) + + def test_publish_multi_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Datasource._generate_xml(new_datasource, + connection_credentials=connection_creds, + connections=[connection1]) diff --git a/test/test_workbook.py b/test/test_workbook.py index de8f8fbaf..7aab1279b 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,7 +2,10 @@ import os import requests_mock import tableauserverclient as TSC +import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.request_factory import RequestFactory TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -316,3 +319,50 @@ def test_publish_invalid_file_type(self): self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleDS.tds'), self.server.PublishMode.CreateNew) + + def test_publish_multi_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2 = TSC.ConnectionItem() + connection2.server_address = 'pgsql.test.com' + connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = ET.fromstring(response).findall('.//connection') + + self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') + self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') + self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') + self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + + def test_publish_single_connection(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = ET.fromstring(response).findall('.//connectionCredentials') + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get('name', None), 'test') + self.assertEqual(credentials[0].get('password', None), 'secret') + self.assertEqual(credentials[0].get('embed', None), 'true') + + def test_credentials_and_multi_connect_raises_exception(self): + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = 'mysql.test.com' + connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + + with self.assertRaises(RuntimeError): + response = RequestFactory.Workbook._generate_xml(new_workbook, + connection_credentials=connection_creds, + connections=[connection1]) From 29a1c607b457a5a15da258bb37a89e0d5468c492 Mon Sep 17 00:00:00 2001 From: Anip Mehta Date: Wed, 30 May 2018 13:46:37 -0700 Subject: [PATCH 013/567] fixes issue #296 (#297) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d8f66264d..d479807d2 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -190,7 +190,7 @@ def create_req(self, project_item): if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions if project_item.parent_id: - project_element.attrib['parentId'] = project_item.parent_id + project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) From 6ddbb80e15101d0292352a45552561cba23f9fb6 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Thu, 31 May 2018 11:32:20 -0400 Subject: [PATCH 014/567] initial checkin for get background jobs (#298) * initial checkin for get background jobs * pep8 fixes * addressed code review feedback --- samples/list.py | 6 +- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/job_item.py | 85 +++++++++++++++++++ tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/endpoint.py | 21 ++--- .../server/endpoint/jobs_endpoint.py | 17 +++- tableauserverclient/server/server.py | 14 +++ test/assets/job_get.xml | 10 +++ test/test_job.py | 45 ++++++++++ 10 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 test/assets/job_get.xml create mode 100644 test/test_job.py diff --git a/samples/list.py b/samples/list.py index d1a25f08d..090d7dfdf 100644 --- a/samples/list.py +++ b/samples/list.py @@ -21,7 +21,7 @@ def main(): 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', 'view']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job']) args = parser.parse_args() @@ -41,7 +41,9 @@ def main(): endpoint = { 'workbook': server.workbooks, 'datasource': server.datasources, - 'view': server.views + 'view': server.views, + 'job': server.jobs, + 'project': server.projects, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 30ec47981..3f2970281 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ - GroupItem, JobItem, PaginationItem, ProjectItem, ScheduleItem, \ + GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 1ff6be869..710831e07 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -4,7 +4,7 @@ from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval -from .job_item import JobItem +from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc53765ed..f8b68d87f 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime from .target import Target @@ -58,3 +59,87 @@ def _parse_element(cls, element, ns): completed_at = element.get('completedAt', None) finish_code = element.get('finishCode', -1) return cls(id_, type_, created_at, started_at, completed_at, finish_code) + + +class BackgroundJobItem(object): + class Status: + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Failed = "Failed" + Cancelled = "Cancelled" + + def __init__(self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, + ended_at=None): + self._id = id_ + self._type = job_type + self._status = status + self._created_at = created_at + self._started_at = started_at + self._ended_at = ended_at + self._priority = priority + self._title = title + self._subtitle = subtitle + + @property + def id(self): + return self._id + + @property + def name(self): + """For API consistency - all other resource endpoints have a name attribute which is used to display what + they are. Alias title as name to allow consistent handling of resources in the list sample.""" + return self._title + + @property + def status(self): + return self._status + + @property + def type(self): + return self._type + + @property + def created_at(self): + return self._created_at + + @property + def started_at(self): + return self._started_at + + @property + def ended_at(self): + return self._ended_at + + @property + def title(self): + return self._title + + @property + def subtitle(self): + return self._subtitle + + @property + def priority(self): + return self._priority + + @classmethod + def from_response(cls, xml, ns): + parsed_response = ET.fromstring(xml) + all_tasks_xml = parsed_response.findall( + './/t:backgroundJob', namespaces=ns) + return [cls._parse_element(x, ns) for x in all_tasks_xml] + + @classmethod + def _parse_element(cls, element, ns): + id_ = element.get('id', None) + type_ = element.get('jobType', None) + status = element.get('status', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + ended_at = parse_datetime(element.get('endedAt', None)) + priority = element.get('priority', None) + title = element.get('title', None) + subtitle = element.get('subtitle', None) + + return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 704fdb66a..8c5cb314c 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,7 +2,7 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, \ +from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index a19c32acd..1efb32f89 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -29,9 +29,9 @@ def _make_common_headers(auth_token, content_type): @staticmethod def _safe_to_log(server_response): - '''Checks if the server_response content is not xml (eg binary image or zip) - and and replaces it with a constant - ''' + """Checks if the server_response content is not xml (eg binary image or zip) + and replaces it with a constant + """ ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: return '[Truncated File Contents]' @@ -90,7 +90,7 @@ def post_request(self, url, xml_request, content_type='text/xml'): def api(version): - '''Annotate the minimum supported version for an endpoint. + """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. It will raise an exception if the server version is > the version specified. @@ -106,23 +106,18 @@ def api(version): >>> @api(version="2.3") >>> def get(self, req_options=None): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - server_version = Version(self.parent_srv.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported) - raise EndpointUnavailableError(error) + self.parent_srv.assert_at_least_version(version) return func(self, *args, **kwargs) return wrapper return _decorator def parameter_added_in(**params): - '''Annotate minimum versions for new parameters or request options on an endpoint. + """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates keyword arguments on endpoints that may control functionality added after an endpoint was introduced. @@ -142,7 +137,7 @@ def parameter_added_in(**params): >>> @parameter_added_in(no_extract='2.5') >>> def download(self, workbook_id, filepath=None, extract_only=False): >>> ... - ''' + """ def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 243b04d63..007f550ae 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api -from .. import JobItem +from .. import JobItem, BackgroundJobItem, PaginationItem import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -11,7 +11,20 @@ def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version='2.6') - def get(self, job_id): + def get(self, job_id=None, req_options=None): + # Backwards Compatibility fix until we rev the major version + if job_id is not None and isinstance(job_id, basestring): + import warnings + warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") + return self.get_by_id(job_id) + self.parent_srv.assert_at_least_version('3.1') + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) + return jobs, pagination_item + + @api(version='2.6') + def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 0c2b4f1c2..95ee564ee 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,9 +4,15 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs +from .endpoint.exceptions import EndpointUnavailableError import requests +try: + from distutils2.version import NormalizedVersion as Version +except ImportError: + from distutils.version import LooseVersion as Version + _PRODUCT_TO_REST_VERSION = { '10.0': '2.3', '9.3': '2.2', @@ -94,6 +100,14 @@ def use_highest_version(self): import warnings warnings.warn("use use_server_version instead", DeprecationWarning) + def assert_at_least_version(self, version): + server_version = Version(self.version or "0.0") + minimum_supported = Version(version) + if server_version < minimum_supported: + error = "This endpoint is not available in API version {}. Requires {}".format( + server_version, minimum_supported) + raise EndpointUnavailableError(error) + @property def baseurl(self): return "{0}/api/{1}".format(self._server_address, str(self.version)) diff --git a/test/assets/job_get.xml b/test/assets/job_get.xml new file mode 100644 index 000000000..4a9f271cc --- /dev/null +++ b/test/assets/job_get.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/test/test_job.py b/test/test_job.py new file mode 100644 index 000000000..674e54c67 --- /dev/null +++ b/test/test_job.py @@ -0,0 +1,45 @@ +import unittest +import os +from datetime import datetime +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import utc + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') + + +class JobTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '3.1' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.jobs.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) + all_jobs, pagination_item = self.server.jobs.get() + job = all_jobs[0] + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + 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) + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) From dd87aa6e51aae277409ba9c530a549e0a6a50722 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 1 Jun 2018 12:06:36 -0400 Subject: [PATCH 015/567] Add cancel job (#299) * added cancel job * fixing whitespace issues * addressing small nits in the sample and the docs --- samples/kill_all_jobs.py | 47 +++++++++++++++++++ .../server/endpoint/endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 9 ++++ tableauserverclient/server/request_options.py | 7 +++ test/test_job.py | 6 +++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 samples/kill_all_jobs.py diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py new file mode 100644 index 000000000..9c5f52a50 --- /dev/null +++ b/samples/kill_all_jobs.py @@ -0,0 +1,47 @@ +#### +# This script demonstrates how to kill all of the running jobs +# +# To run the script, you must have installed Python 2.7.X or 3.3 and later. +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Cancel all of the running background jobs') + 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('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + args = parser.parse_args() + + if args.password is None: + password = getpass.getpass("Password: ") + else: + password = args.password + + # 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) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + req = TSC.RequestOptions() + + req.filter.add(TSC.Filter("progress", TSC.RequestOptions.Operator.LessThanOrEqual, 0)) + for job in TSC.Pager(server.jobs, request_opts=req): + print(server.jobs.cancel(job.id), job.id, job.status, job.type) + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 1efb32f89..994d2133d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -76,7 +76,7 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request, content_type='text/xml'): + def put_request(self, url, xml_request=None, content_type='text/xml'): return self._make_request(self.parent_srv.session.put, url, content=xml_request, auth_token=self.parent_srv.auth_token, diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 007f550ae..f3432d605 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,6 @@ from .endpoint import Endpoint, api from .. import JobItem, BackgroundJobItem, PaginationItem +from ..request_options import RequestOptionsBase import logging logger = logging.getLogger('tableau.endpoint.jobs') @@ -17,12 +18,20 @@ def get(self, job_id=None, req_options=None): import warnings warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") return self.get_by_id(job_id) + if isinstance(job_id, RequestOptionsBase): + req_options = job_id + self.parent_srv.assert_at_least_version('3.1') server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) return jobs, pagination_item + @api(version='3.1') + def cancel(self, job_id): + url = '{0}/{1}'.format(self.baseurl, job_id) + return self.put_request(url) + @api(version='2.6') def get_by_id(self, job_id): logger.info('Query for information about job ' + job_id) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index be00e8975..0e3601a25 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -13,20 +13,27 @@ class Operator: In = 'in' class Field: + Args = 'args' + CompletedAt = 'completedAt' CreatedAt = 'createdAt' DomainName = 'domainName' DomainNickname = 'domainNickname' HitsTotal = 'hitsTotal' IsLocal = 'isLocal' + JobType = 'jobType' LastLogin = 'lastLogin' MinimumSiteRole = 'minimumSiteRole' Name = 'name' + Notes = 'notes' OwnerDomain = 'ownerDomain' OwnerEmail = 'ownerEmail' OwnerName = 'ownerName' + Progress = 'progress' ProjectName = 'projectName' SiteRole = 'siteRole' + Subtitle = 'subtitle' Tags = 'tags' + Title = 'title' Type = 'type' UpdatedAt = 'updatedAt' UserCount = 'userCount' diff --git a/test/test_job.py b/test/test_job.py index 674e54c67..5da0f76fa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -31,6 +31,7 @@ def test_get(self): created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) 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) @@ -43,3 +44,8 @@ def test_get(self): def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) + + def test_cancel(self): + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From a6e1631b29f05b40a77d3698ffc70b05be1a1ffb Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Fri, 29 Jun 2018 18:13:59 -0700 Subject: [PATCH 016/567] Added asynchronous publish support for workbook and datasource endpoint --- samples/publish_workbook.py | 8 +++++-- tableauserverclient/models/job_item.py | 20 +++++++++++------- .../server/endpoint/datasources_endpoint.py | 18 ++++++++++++---- .../server/endpoint/workbooks_endpoint.py | 17 +++++++++++---- test/assets/datasource_publish_async.xml | 4 ++++ test/assets/workbook_publish_async.xml | 4 ++++ test/test_datasource.py | 17 +++++++++++++++ test/test_workbook.py | 21 +++++++++++++++++-- 8 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 test/assets/datasource_publish_async.xml create mode 100644 test/assets/workbook_publish_async.xml diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 37d66d2dc..6ff12910a 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -29,6 +29,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + parser.add_argument('--async', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -53,8 +54,11 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true) - print("Workbook published. ID: {0}".format(new_workbook.id)) + response = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.async) + if args.async is True: + print("Workbook published. JOB ID: {0}".format(response.id)) + else: + print("Workbook published. ID: {0}".format(response.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc53765ed..41208a0be 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,11 +1,12 @@ import xml.etree.ElementTree as ET from .target import Target - +from ..datetime_helpers import parse_datetime class JobItem(object): - def __init__(self, id_, job_type, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): self._id = id_ self._type = job_type + self._progress = progress self._created_at = created_at self._started_at = started_at self._completed_at = completed_at @@ -19,6 +20,10 @@ def id(self): def type(self): return self._type + @property + def progress(self): + return self._progress + @property def created_at(self): return self._created_at @@ -37,7 +42,7 @@ def finish_code(self): def __repr__(self): return "".format(**self.__dict__) + " progress ({_progress}) finish_code({_finish_code})>".format(**self.__dict__) @classmethod def from_response(cls, xml, ns): @@ -53,8 +58,9 @@ def from_response(cls, xml, ns): def _parse_element(cls, element, ns): id_ = element.get('id', None) type_ = element.get('type', None) - created_at = element.get('createdAt', None) - started_at = element.get('startedAt', None) - completed_at = element.get('completedAt', None) + progress = element.get('progress', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, created_at, started_at, completed_at, finish_code) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 03e261765..91d6be940 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,7 +151,8 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - def publish(self, datasource_item, file_path, mode, connection_credentials=None): + @parameter_added_in(as_job='3.0') + def publish(self, datasource_item, file_path, mode, as_job=False, connection_credentials=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -174,6 +175,9 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: url += '&{0}=true'.format(mode.lower()) + if as_job is True: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (datasource over 64MB)'.format(filename)) @@ -190,6 +194,12 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None) file_contents, connection_credentials) server_response = self.post_request(url, xml_request, content_type) - new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) - return new_datasource + + if as_job is True: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + return new_datasource diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4ce9983f3..950f36d11 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,7 +199,8 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") - def publish(self, workbook_item, file_path, mode, connection_credentials=None): + @parameter_added_in(as_job='3.0') + def publish(self, workbook_item, file_path, mode, as_job=False, connection_credentials=None): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -225,6 +226,9 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None): error = 'Workbooks cannot be appended.' raise ValueError(error) + if as_job is True: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) @@ -241,6 +245,11 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None): file_contents, connection_credentials) server_response = self.post_request(url, xml_request, content_type) - new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) - return new_workbook + if as_job is True: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) + return new_workbook diff --git a/test/assets/datasource_publish_async.xml b/test/assets/datasource_publish_async.xml new file mode 100644 index 000000000..a32fccd2a --- /dev/null +++ b/test/assets/datasource_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/workbook_publish_async.xml b/test/assets/workbook_publish_async.xml new file mode 100644 index 000000000..21e4e83ed --- /dev/null +++ b/test/assets/workbook_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index ff1546d62..4d19fbb3b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -11,6 +11,7 @@ GET_BY_ID_XML = 'datasource_get_by_id.xml' POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' PUBLISH_XML = 'datasource_publish.xml' +PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -186,6 +187,22 @@ def test_publish(self): self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + def test_publish_async(self): + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_job = self.server.datasources.publish(new_datasource, + asset('SampleDS.tds'), + mode=self.server.PublishMode.CreateNew, + as_job=True) + + self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) + self.assertEqual('PublishDatasource', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) diff --git a/test/test_workbook.py b/test/test_workbook.py index 8c36f0229..67c9c0b32 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -15,6 +15,7 @@ POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') @@ -304,10 +305,26 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_async(self): + 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) + new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_job = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), + self.server.PublishMode.CreateNew, True) + + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual('PublishWorkbook', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, - '.', self.server.PublishMode.CreateNew) + raises = self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', + self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From ef71db0ca4f3237eb94d24552d6878f9bca1e303 Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Fri, 29 Jun 2018 18:21:24 -0700 Subject: [PATCH 017/567] Reverting a typo --- test/test_workbook.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_workbook.py b/test/test_workbook.py index 67c9c0b32..9e0fbedb8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -319,11 +319,12 @@ def test_publish_async(self): self.assertEqual('PublishWorkbook', new_job.type) self.assertEqual('0', new_job.progress) self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) self.assertEqual('1', new_job.finish_code) def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - raises = self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): From 8cc3032687b1318da1d6af69889c0631417b9ed9 Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Fri, 29 Jun 2018 19:11:28 -0700 Subject: [PATCH 018/567] Fixing unittests --- samples/publish_workbook.py | 11 ++++++----- tableauserverclient/models/job_item.py | 1 + test/test_workbook.py | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 6ff12910a..137aba29e 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -29,7 +29,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('--async', '-a', help='Publishing asynchronously', action='store_true') + parser.add_argument('--as_job', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -54,11 +54,12 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - response = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.async) - if args.async is True: - print("Workbook published. JOB ID: {0}".format(response.id)) + if args.as_job is True: + new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.as_job) + print("Workbook published. JOB ID: {0}".format(new_job.id)) else: - print("Workbook published. ID: {0}".format(response.id)) + new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.as_job) + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 41208a0be..88c9057cf 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -2,6 +2,7 @@ from .target import Target from ..datetime_helpers import parse_datetime + class JobItem(object): def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): self._id = id_ diff --git a/test/test_workbook.py b/test/test_workbook.py index 9e0fbedb8..25363afa5 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -319,7 +319,6 @@ def test_publish_async(self): self.assertEqual('PublishWorkbook', new_job.type) self.assertEqual('0', new_job.progress) self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) self.assertEqual('1', new_job.finish_code) def test_publish_invalid_file(self): From f5f57f4d8d42524eadb41bdedfbf53fdd545d570 Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Fri, 29 Jun 2018 19:13:34 -0700 Subject: [PATCH 019/567] Fixed indentation --- test/test_workbook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_workbook.py b/test/test_workbook.py index 25363afa5..32e7159f0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -313,7 +313,7 @@ def test_publish_async(self): new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') new_job = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew, True) + self.server.PublishMode.CreateNew, True) self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) self.assertEqual('PublishWorkbook', new_job.type) @@ -324,7 +324,7 @@ def test_publish_async(self): def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', - self.server.PublishMode.CreateNew) + self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From 1fcdc12f045876333faa21c24347e975926f80f2 Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Mon, 2 Jul 2018 09:17:47 -0700 Subject: [PATCH 020/567] Addressing comment --- samples/publish_workbook.py | 4 ++-- tableauserverclient/server/endpoint/datasources_endpoint.py | 6 +++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 137aba29e..82beb0850 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -29,7 +29,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('--as_job', '-a', help='Publishing asynchronously', action='store_true') + parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -54,7 +54,7 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - if args.as_job is True: + if args.as_job: new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.as_job) print("Workbook published. JOB ID: {0}".format(new_job.id)) else: diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 91d6be940..30fce963a 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -152,7 +152,7 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") @parameter_added_in(as_job='3.0') - def publish(self, datasource_item, file_path, mode, as_job=False, connection_credentials=None): + def publish(self, datasource_item, file_path, mode, connection_credentials=None, as_job=False): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -175,7 +175,7 @@ def publish(self, datasource_item, file_path, mode, as_job=False, connection_cre if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: url += '&{0}=true'.format(mode.lower()) - if as_job is True: + if as_job: url += '&{0}=true'.format('asJob') # Determine if chunking is required (64MB is the limit for single upload method) @@ -195,7 +195,7 @@ def publish(self, datasource_item, file_path, mode, as_job=False, connection_cre connection_credentials) server_response = self.post_request(url, xml_request, content_type) - if as_job is True: + if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) return new_job diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 950f36d11..72936b277 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -200,7 +200,7 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") @parameter_added_in(as_job='3.0') - def publish(self, workbook_item, file_path, mode, as_job=False, connection_credentials=None): + def publish(self, workbook_item, file_path, mode, connection_credentials=None, as_job=False): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -226,7 +226,7 @@ def publish(self, workbook_item, file_path, mode, as_job=False, connection_crede error = 'Workbooks cannot be appended.' raise ValueError(error) - if as_job is True: + if as_job: url += '&{0}=true'.format('asJob') # Determine if chunking is required (64MB is the limit for single upload method) @@ -245,7 +245,7 @@ def publish(self, workbook_item, file_path, mode, as_job=False, connection_crede file_contents, connection_credentials) server_response = self.post_request(url, xml_request, content_type) - if as_job is True: + if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) return new_job From d5bd7c888f8e26605af10795c964da5eb1d92af7 Mon Sep 17 00:00:00 2001 From: Ang Gao Date: Tue, 3 Jul 2018 19:32:19 -0700 Subject: [PATCH 021/567] Make it shiny! ... and fix failed test ;) --- samples/publish_workbook.py | 4 ++-- test/test_datasource.py | 8 ++++++-- test/test_workbook.py | 27 +++++++++++++++++++++------ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 82beb0850..dea1d5bf0 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -55,10 +55,10 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) if args.as_job: - new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.as_job) + new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, as_job=args.as_job) print("Workbook published. JOB ID: {0}".format(new_job.id)) else: - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, args.as_job) + new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, as_job=args.as_job) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/test/test_datasource.py b/test/test_datasource.py index 4d19fbb3b..6022ce50e 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -173,9 +173,11 @@ def test_publish(self): with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + new_datasource = self.server.datasources.publish(new_datasource, asset('SampleDS.tds'), - mode=self.server.PublishMode.CreateNew) + mode=publish_mode) self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) self.assertEqual('SampleDS', new_datasource.name) @@ -192,9 +194,11 @@ def test_publish_async(self): with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + new_job = self.server.datasources.publish(new_datasource, asset('SampleDS.tds'), - mode=self.server.PublishMode.CreateNew, + mode=publish_mode, as_job=True) self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) diff --git a/test/test_workbook.py b/test/test_workbook.py index 32e7159f0..9657997d3 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -286,10 +286,17 @@ def test_publish(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - new_workbook = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew) + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) self.assertEqual('RESTAPISample', new_workbook.name) @@ -310,10 +317,18 @@ def test_publish_async(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - new_job = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew, True) + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode, + as_job=True) self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) self.assertEqual('PublishWorkbook', new_job.type) From 3a00122b822b8223a7f32edcc737c3aca3b0ccfe Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Fri, 6 Jul 2018 08:28:40 -0700 Subject: [PATCH 022/567] Prep v0.7 (#310) * Adding chnagelog for 0.7 * Adding Sergey to contributors * Adding async to the changelog --- CHANGELOG.md | 17 +++++++++++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8950a4cb..77aab3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.7 (2 Jul 2018) + +* Added cancel job (#299) +* Added Get background jobs (#298) +* Added Multi-credential support (#276) +* Added Update Groups (#279) +* Adding project_id to view (#285) +* Added ability to rename workbook using `update workbook` (#284) +* Added Sample for exporting full pdf using pdf page combining (#267) +* Added Sample for exporting data, images, and single view pdfs (#263) +* Added view filters to the populate request options (#260) +* Add Async publishing for workbook and datasource endpoints (#311) +* Fixed ability to update datasource server connection port (#283) +* Fixed next project handling (#267) +* Cleanup debugging output to strip out non-xml response +* Improved refresh sample for readability (#288) + ## 0.6.1 (26 Jan 2018) * Fixed #257 where refreshing extracts does not work due to a missing "self" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4143570cf..25ac5718b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,6 +15,7 @@ The following people have contributed to this project to make it possible, and w * [William Lang](https://github.com/williamlang) * [Jim Morris](https://github.com/jimbodriven) * [BingoDinkus](https://github.com/BingoDinkus) +* [Sergey Sotnichenko](https://github.com/sotnich) ## Core Team From 59bf8920730e9877675c31885d538748e7e36bfe Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 10 Jul 2018 13:48:31 -0700 Subject: [PATCH 023/567] Merge multicredential and as job (#313) Merging things since as_job was added in master by accident --- docs/docs/api-ref.md | 3 +- samples/publish_workbook.py | 14 ++++--- tableauserverclient/models/job_item.py | 19 ++++++--- tableauserverclient/models/user_item.py | 11 +++++ .../server/endpoint/datasources_endpoint.py | 20 ++++++--- .../server/endpoint/workbooks_endpoint.py | 17 ++++++-- test/assets/datasource_publish_async.xml | 4 ++ test/assets/workbook_publish_async.xml | 4 ++ test/test_datasource.py | 23 +++++++++- test/test_workbook.py | 42 ++++++++++++++++--- 10 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 test/assets/datasource_publish_async.xml create mode 100644 test/assets/workbook_publish_async.xml diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 81d1211dd..68f86321f 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -1077,7 +1077,7 @@ The project resources for Tableau are defined in the `ProjectItem` class. The cl ```py -ProjectItem(name, description=None, content_permissions=None) +ProjectItem(name, description=None, content_permissions=None, parent_id=None) ``` The project resources for Tableau are defined in the `ProjectItem` class. The class corresponds to the project resources you can access using the Tableau Server REST API. @@ -1090,6 +1090,7 @@ Name | Description `name` | Name of the project. `description` | The description of the project. `id` | The project id. +`parent_id` | The parent project id. diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 6798a2106..2d460abaf 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -30,6 +30,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') args = parser.parse_args() @@ -67,11 +68,14 @@ def main(): # Step 3: If default project is found, form a new workbook item and publish. if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) - new_workbook = server.workbooks.publish(new_workbook, - args.filepath, - overwrite_true, - connections=all_connections) - print("Workbook published. ID: {0}".format(new_workbook.id)) + if args.as_job: + new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. JOB ID: {0}".format(new_job.id)) + else: + new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, + connections=all_connections, as_job=args.as_job) + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f8b68d87f..6ad7f0256 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,14 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime from .target import Target +from ..datetime_helpers import parse_datetime class JobItem(object): - def __init__(self, id_, job_type, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): self._id = id_ self._type = job_type + self._progress = progress self._created_at = created_at self._started_at = started_at self._completed_at = completed_at @@ -20,6 +22,10 @@ def id(self): def type(self): return self._type + @property + def progress(self): + return self._progress + @property def created_at(self): return self._created_at @@ -38,7 +44,7 @@ def finish_code(self): def __repr__(self): return "".format(**self.__dict__) + " progress ({_progress}) finish_code({_finish_code})>".format(**self.__dict__) @classmethod def from_response(cls, xml, ns): @@ -54,11 +60,12 @@ def from_response(cls, xml, ns): def _parse_element(cls, element, ns): id_ = element.get('id', None) type_ = element.get('type', None) - created_at = element.get('createdAt', None) - started_at = element.get('startedAt', None) - completed_at = element.get('completedAt', None) + progress = element.get('progress', None) + created_at = parse_datetime(element.get('createdAt', None)) + started_at = parse_datetime(element.get('startedAt', None)) + completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, created_at, started_at, completed_at, finish_code) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) class BackgroundJobItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b0da7b3d0..47d12b662 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -16,6 +16,14 @@ class Roles: ViewerWithPublish = 'ViewerWithPublish' Guest = 'Guest' + Creator = 'Creator' + Explorer = 'Explorer' + ExplorerCanPublish = 'ExplorerCanPublish' + ReadOnly = 'ReadOnly' + SiteAdministratorCreator = 'SiteAdministratorCreator' + SiteAdministratorExplorer = 'SiteAdministratorExplorer' + UnlicensedWithPublish = 'UnlicensedWithPublish' + class Auth: SAML = 'SAML' ServerDefault = 'ServerDefault' @@ -147,3 +155,6 @@ def _parse_element(user_xml, ns): domain_name = domain_elem.get('name', None) return id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + + def __repr__(self): + return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 5e986f91c..904d27144 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,8 +151,9 @@ def refresh(self, datasource_item): # Publish datasource @api(version="2.0") - @parameter_added_in(connections="99.99") - def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None): + @parameter_added_in(connections="2.8") + @parameter_added_in(as_job='3.0') + def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -175,6 +176,9 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: url += '&{0}=true'.format(mode.lower()) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (datasource over 64MB)'.format(filename)) @@ -193,6 +197,12 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, connection_credentials, connections) server_response = self.post_request(url, xml_request, content_type) - new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) - return new_datasource + + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + return new_datasource diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 537e3ec81..79b15f379 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -199,8 +199,9 @@ def _get_wb_preview_image(self, workbook_item): # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") + @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None): + def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): if connection_credentials is not None: import warnings @@ -232,6 +233,9 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c error = 'Workbooks cannot be appended.' raise ValueError(error) + if as_job: + url += '&{0}=true'.format('asJob') + # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) @@ -253,6 +257,11 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c connections=connections) logger.debug('Request xml: {0} '.format(xml_request[:1000])) server_response = self.post_request(url, xml_request, content_type) - new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) - return new_workbook + if as_job: + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + return new_job + else: + new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) + return new_workbook diff --git a/test/assets/datasource_publish_async.xml b/test/assets/datasource_publish_async.xml new file mode 100644 index 000000000..a32fccd2a --- /dev/null +++ b/test/assets/datasource_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/workbook_publish_async.xml b/test/assets/workbook_publish_async.xml new file mode 100644 index 000000000..21e4e83ed --- /dev/null +++ b/test/assets/workbook_publish_async.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 9ddf5a3c8..1b21c0194 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -13,6 +13,7 @@ GET_BY_ID_XML = 'datasource_get_by_id.xml' POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' PUBLISH_XML = 'datasource_publish.xml' +PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -178,9 +179,11 @@ def test_publish(self): with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + new_datasource = self.server.datasources.publish(new_datasource, asset('SampleDS.tds'), - mode=self.server.PublishMode.CreateNew) + mode=publish_mode) self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) self.assertEqual('SampleDS', new_datasource.name) @@ -192,6 +195,24 @@ def test_publish(self): self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + def test_publish_async(self): + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.datasources.publish(new_datasource, + asset('SampleDS.tds'), + mode=publish_mode, + as_job=True) + + self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) + self.assertEqual('PublishDatasource', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) diff --git a/test/test_workbook.py b/test/test_workbook.py index 7aab1279b..d4e2275f4 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,6 +18,7 @@ POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') @@ -290,10 +291,17 @@ def test_publish(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - new_workbook = self.server.workbooks.publish(new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx'), - self.server.PublishMode.CreateNew) + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) self.assertEqual('RESTAPISample', new_workbook.name) @@ -309,10 +317,34 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_async(self): + 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) + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_job = self.server.workbooks.publish(new_workbook, + sample_workbok, + publish_mode, + as_job=True) + + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual('PublishWorkbook', new_job.type) + self.assertEqual('0', new_job.progress) + self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual('1', new_job.finish_code) + def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, - '.', self.server.PublishMode.CreateNew) + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', + self.server.PublishMode.CreateNew) def test_publish_invalid_file_type(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From 6094eb31f9a3c40dcf58ac90ee680da3d5ced8dc Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 19 Jul 2018 14:54:26 -0700 Subject: [PATCH 024/567] Update Pager to handle un-paged lists (#322) Not all endpoints support pagination on the server-side. We don't really give a way to predict what will or won't work with `Pager`, so I've updated it to check for a `total_available` amount. If that is `None` the endpoint doesn't support paging, and we can just drain the existing list and return. If the endpoint gets updated to support paging, it'll just magically start working. --- tableauserverclient/server/pager.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 78c927dda..92c0f0423 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -36,6 +36,13 @@ def __iter__(self): # Fetch the first page current_item_list, last_pagination_item = self._endpoint(self._options) + if last_pagination_item.total_available is None: + # This endpoint does not support pagination, drain the list and return + while current_item_list: + yield current_item_list.pop(0) + + return + # Get the rest on demand as a generator while self._count < last_pagination_item.total_available: if len(current_item_list) == 0: From 359e6dde4a956a9bfc09138ba276d154e7f7e904 Mon Sep 17 00:00:00 2001 From: Bumsoo Kim Date: Fri, 20 Jul 2018 06:57:39 +0900 Subject: [PATCH 025/567] fixes datasource chunked upload (#309) (#319) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d479807d2..6b6079a58 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -102,7 +102,7 @@ def publish_req(self, datasource_item, filename, file_contents, connection_crede 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, datasource_item, connection_credentials=None): + def publish_req_chunked(self, datasource_item, connection_credentials=None, connections=None): xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = {'request_payload': ('', xml_request, 'text/xml')} From d688025508551844d9dc39f3fb880c15be00fcfb Mon Sep 17 00:00:00 2001 From: Bumsoo Kim Date: Tue, 21 Aug 2018 07:18:58 +0900 Subject: [PATCH 026/567] fixes datasource chunked upload (#326) (#329) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6b6079a58..48a33005e 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -391,7 +391,7 @@ def publish_req(self, workbook_item, filename, file_contents, connection_credent 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connections=None): + def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, connections=connections) From 44581c4cd208c1bfa93af82a738227d9406e9acf Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Aug 2018 22:26:13 +0200 Subject: [PATCH 027/567] add fields to ViewItem model --- tableauserverclient/models/view_item.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 1fc6d4e8e..4feabb451 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,10 +1,13 @@ import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError class ViewItem(object): def __init__(self): self._content_url = None + self._created_at = None + self._favorites_total = None self._id = None self._image = None self._initial_tags = set() @@ -15,6 +18,8 @@ def __init__(self): self._pdf = None self._csv = None self._total_views = None + self._sheet_type = None + self._updated_at = None self._workbook_id = None self.tags = set() @@ -34,6 +39,14 @@ def _set_csv(self, csv): def content_url(self): return self._content_url + @property + def created_at(self): + return self._created_at + + @property + def favorites_total(self): + return self._favorites_total + @property def id(self): return self._id @@ -78,6 +91,10 @@ def csv(self): raise UnpopulatedPropertyError(error) return self._csv() + @property + def sheet_type(self): + return self._sheet_type + @property def total_views(self): if self._total_views is None: @@ -85,6 +102,10 @@ def total_views(self): raise UnpopulatedPropertyError(error) return self._total_views + @property + def updated_at(self): + return self._updated_at + @property def workbook_id(self): return self._workbook_id @@ -103,9 +124,17 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) owner_elem = view_xml.find('.//t:owner', namespaces=ns) project_elem = view_xml.find('.//t:project', namespaces=ns) + favoritesTotal = view_xml.get('favoritesTotal', None) + view_item._created_at = parse_datetime(view_xml.get('createdAt', None)) + view_item._updated_at = parse_datetime(view_xml.get('updatedAt', None)) view_item._id = view_xml.get('id', None) view_item._name = view_xml.get('name', None) view_item._content_url = view_xml.get('contentUrl', None) + view_item._sheet_type = view_xml.get('sheetType', None) + + if favoritesTotal: + view_item._favorites_total = int(favoritesTotal) + if usage_elem is not None: total_view = usage_elem.get('totalViewCount', None) if total_view: From d27993d2205a96b7d208de723ce6654c250413fd Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Aug 2018 22:26:50 +0200 Subject: [PATCH 028/567] update test assets and unit tests with new fields --- test/assets/view_get.xml | 4 ++-- test/assets/view_get_usage.xml | 4 ++-- test/test_view.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index 36f43e255..7e18cd8a8 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -7,10 +7,10 @@ - + - \ No newline at end of file + diff --git a/test/assets/view_get_usage.xml b/test/assets/view_get_usage.xml index a6844879d..a2b5a71c6 100644 --- a/test/assets/view_get_usage.xml +++ b/test/assets/view_get_usage.xml @@ -8,11 +8,11 @@ - + - \ No newline at end of file + diff --git a/test/test_view.py b/test/test_view.py index 292f86887..602969c74 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -3,6 +3,8 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'view_add_tags.xml') @@ -40,6 +42,10 @@ def test_get(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) + self.assertIsNone(all_views[0].created_at) + self.assertIsNone(all_views[0].updated_at) + self.assertIsNone(all_views[0].favorites_total) + self.assertIsNone(all_views[0].sheet_type) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual('Overview', all_views[1].name) @@ -47,6 +53,10 @@ def test_get(self): self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id) self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) + self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) + self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) + self.assertEqual(2, all_views[1].favorites_total) + self.assertEqual('story', all_views[1].sheet_type) def test_get_with_usage(self): with open(GET_XML_USAGE, 'rb') as f: @@ -57,8 +67,17 @@ def test_get_with_usage(self): self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id) self.assertEqual(7, all_views[0].total_views) + self.assertIsNone(all_views[0].created_at) + self.assertIsNone(all_views[0].updated_at) + self.assertIsNone(all_views[0].favorites_total) + self.assertIsNone(all_views[0].sheet_type) + self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual(13, all_views[1].total_views) + self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) + self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) + self.assertEqual(2, all_views[1].favorites_total) + self.assertEqual('story', all_views[1].sheet_type) def test_get_with_usage_and_filter(self): with open(GET_XML_USAGE, 'rb') as f: From 2216d435ce3eced3291056b4df65b270fc0b8b8a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Aug 2018 16:59:37 +0200 Subject: [PATCH 029/567] add new fields to docs --- docs/docs/api-ref.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 68f86321f..ae67ebc32 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -2405,11 +2405,15 @@ Source file: models/view_item.py Name | Description :--- | :--- +`created_at` | The date and time when the view was created. +`favorites_total` | The number of times the view was marked down as favorite. `id` | The identifier of the view item. `name` | The name of the view. `owner_id` | The id for the owner of the view. `preview_image` | The thumbnail image for the view. +`sheet_type` | The type of the view which is either a worksheet, a dashboard or a story. `total_views` | The usage statistics for the view. Indicates the total number of times the view has been looked at. +`updated_at` | The date and time when the view was last updated. `workbook_id` | The id of the workbook associated with the view. From 2c7e538ad521037161370b74a7749ccc7cc9fa88 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Aug 2018 17:42:54 +0200 Subject: [PATCH 030/567] remove favoritesTotal only available from api 3.1 --- tableauserverclient/models/view_item.py | 9 --------- test/assets/view_get.xml | 2 +- test/assets/view_get_usage.xml | 2 +- test/test_view.py | 4 ---- 4 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 4feabb451..c4d36a841 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -7,7 +7,6 @@ class ViewItem(object): def __init__(self): self._content_url = None self._created_at = None - self._favorites_total = None self._id = None self._image = None self._initial_tags = set() @@ -43,10 +42,6 @@ def content_url(self): def created_at(self): return self._created_at - @property - def favorites_total(self): - return self._favorites_total - @property def id(self): return self._id @@ -124,7 +119,6 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) owner_elem = view_xml.find('.//t:owner', namespaces=ns) project_elem = view_xml.find('.//t:project', namespaces=ns) - favoritesTotal = view_xml.get('favoritesTotal', None) view_item._created_at = parse_datetime(view_xml.get('createdAt', None)) view_item._updated_at = parse_datetime(view_xml.get('updatedAt', None)) view_item._id = view_xml.get('id', None) @@ -132,9 +126,6 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): view_item._content_url = view_xml.get('contentUrl', None) view_item._sheet_type = view_xml.get('sheetType', None) - if favoritesTotal: - view_item._favorites_total = int(favoritesTotal) - if usage_elem is not None: total_view = usage_elem.get('totalViewCount', None) if total_view: diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index 7e18cd8a8..f205edf6a 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -7,7 +7,7 @@ - + diff --git a/test/assets/view_get_usage.xml b/test/assets/view_get_usage.xml index a2b5a71c6..741e607e7 100644 --- a/test/assets/view_get_usage.xml +++ b/test/assets/view_get_usage.xml @@ -8,7 +8,7 @@ - + diff --git a/test/test_view.py b/test/test_view.py index 602969c74..213ea2538 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -44,7 +44,6 @@ def test_get(self): self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) - self.assertIsNone(all_views[0].favorites_total) self.assertIsNone(all_views[0].sheet_type) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) @@ -55,7 +54,6 @@ def test_get(self): self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) - self.assertEqual(2, all_views[1].favorites_total) self.assertEqual('story', all_views[1].sheet_type) def test_get_with_usage(self): @@ -69,14 +67,12 @@ def test_get_with_usage(self): self.assertEqual(7, all_views[0].total_views) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) - self.assertIsNone(all_views[0].favorites_total) self.assertIsNone(all_views[0].sheet_type) self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) self.assertEqual(13, all_views[1].total_views) self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) - self.assertEqual(2, all_views[1].favorites_total) self.assertEqual('story', all_views[1].sheet_type) def test_get_with_usage_and_filter(self): From f05d1c14fa57dda4053669a78fc2f475fda4f8cb Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Aug 2018 17:45:52 +0200 Subject: [PATCH 031/567] remove favorite total from docs --- docs/docs/api-ref.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index ae67ebc32..532d5d9b9 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -2406,7 +2406,6 @@ Source file: models/view_item.py Name | Description :--- | :--- `created_at` | The date and time when the view was created. -`favorites_total` | The number of times the view was marked down as favorite. `id` | The identifier of the view item. `name` | The name of the view. `owner_id` | The id for the owner of the view. From 97aa62291d08c9f7f328ef30ed4d112d4386e628 Mon Sep 17 00:00:00 2001 From: Mary Brennan Date: Tue, 30 Oct 2018 17:18:26 -0700 Subject: [PATCH 032/567] fix link pointing to old doc content The link points to the old version of the dev-guide on the master branch. Point link to the new version on the gh-pages branch by linking to the built docs instead of repo location. --- contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing.md b/contributing.md index 0c856c06a..c95191e0e 100644 --- a/contributing.md +++ b/contributing.md @@ -48,7 +48,7 @@ anyone can add to an issue: ## Fixes, Implementations, and Documentation For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](docs/docs/dev-guide.md) +creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide) If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle From 7b50e2ed172093a2d156e80032f66312f2929711 Mon Sep 17 00:00:00 2001 From: preguraman Date: Mon, 12 Nov 2018 14:13:51 -0800 Subject: [PATCH 033/567] Add maxage param to the download view image request --- samples/download_view_image.py | 7 ++++++- tableauserverclient/server/request_options.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index b95a8628b..730ca9572 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -25,6 +25,7 @@ def main(): parser.add_argument('--view-name', '-v', required=True, help='name of view to download an image of') parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned') + parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -55,8 +56,12 @@ def main(): raise LookupError("View with the specified name was not found.") view_item = all_views[0] + max_age = args.maxage + if not max_age: + max_age = 1 + # Step 3: Query the image endpoint and save the image to the specified location - image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High) + image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage = max_age) server.views.populate_image(view_item, image_req_option) with open(args.filepath, "wb") as image_file: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 0e3601a25..9f3247e7b 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -108,14 +108,17 @@ class ImageRequestOptions(_FilterOptionsBase): class Resolution: High = 'high' - def __init__(self, imageresolution=None): + def __init__(self, imageresolution=None, maxage=None): super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution + self.max_age = maxage def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) + if self.max_age: + params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) From 4afd7ecae8b15459041a9ad06d42e06ae7c1e6f7 Mon Sep 17 00:00:00 2001 From: preguraman Date: Wed, 14 Nov 2018 12:16:38 -0800 Subject: [PATCH 034/567] Fix the code style error --- samples/download_view_image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 730ca9572..df2331596 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -58,10 +58,10 @@ def main(): max_age = args.maxage if not max_age: - max_age = 1 + max_age = 1 - # Step 3: Query the image endpoint and save the image to the specified location - image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage = max_age) + image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, + maxage=max_age) server.views.populate_image(view_item, image_req_option) with open(args.filepath, "wb") as image_file: From cdb0f675c2faa7d899d0d2498ac89d4f869d7ce9 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Thu, 15 Nov 2018 15:29:04 -0800 Subject: [PATCH 035/567] adding basestring conversion to str for python3 (#362) --- tableauserverclient/server/endpoint/jobs_endpoint.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index f3432d605..92285c3db 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -3,6 +3,12 @@ from ..request_options import RequestOptionsBase import logging +try: + basestring +except NameError: + # In case we are in python 3 the string check is different + basestring = str + logger = logging.getLogger('tableau.endpoint.jobs') From 78a353875cd885bacc6bb68bb7e74f5089b9371f Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Thu, 15 Nov 2018 15:30:47 -0800 Subject: [PATCH 036/567] adding new exception to handle 500 errors properly (#361) --- .../server/endpoint/datasources_endpoint.py | 11 +++++++++-- tableauserverclient/server/endpoint/endpoint.py | 6 ++++-- tableauserverclient/server/endpoint/exceptions.py | 9 +++++++++ .../server/endpoint/workbooks_endpoint.py | 12 ++++++++++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 904d27144..4d7a20b70 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in -from .exceptions import MissingRequiredFieldError +from .exceptions import InternalServerError, MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem @@ -196,7 +196,14 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, file_contents, connection_credentials, connections) - server_response = self.post_request(url, xml_request, content_type) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504 and not as_job: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 994d2133d..f16c9f8df 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, EndpointUnavailableError, ItemTypeNotAllowed +from .exceptions import ServerResponseError, InternalServerError from functools import wraps import logging @@ -62,7 +62,9 @@ def _make_request(self, method, url, content=None, request_object=None, def _check_status(self, server_response): logger.debug(self._safe_to_log(server_response)) - if server_response.status_code not in Success_codes: + if server_response.status_code >= 500: + raise InternalServerError(server_response) + elif server_response.status_code not in Success_codes: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) def get_unauthenticated_request(self, url, request_object=None): diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d77cdea3e..080eca9c8 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -21,6 +21,15 @@ def from_response(cls, resp, ns): return error_response +class InternalServerError(Exception): + def __init__(self, server_response): + self.code = server_response.status_code + self.content = server_response.content + + def __str__(self): + return "\n\nError status code: {0}\n{1}".format(self.code, self.content) + + class MissingRequiredFieldError(Exception): pass diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 79b15f379..e4d7da466 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in -from .exceptions import MissingRequiredFieldError +from .exceptions import InternalServerError, MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem @@ -256,7 +256,15 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c connection_credentials=conn_creds, connections=connections) logger.debug('Request xml: {0} '.format(xml_request[:1000])) - server_response = self.post_request(url, xml_request, content_type) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504 and not as_job: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err + if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) From c84bf26fcb2ee9a5baa2469de0de3feeac2f71ec Mon Sep 17 00:00:00 2001 From: bzhang Date: Thu, 17 Jan 2019 16:48:40 -0800 Subject: [PATCH 037/567] Added materialized views settings to site/workbook item, and a sample script to update these settings --- samples/materialize_workbooks.py | 105 ++++++++++++++++++ tableauserverclient/models/site_item.py | 30 +++-- tableauserverclient/models/workbook_item.py | 22 +++- .../server/endpoint/sites_endpoint.py | 13 ++- .../server/endpoint/workbooks_endpoint.py | 6 +- tableauserverclient/server/request_factory.py | 10 +- tableauserverclient/server/server.py | 2 +- test/test_group.py | 4 +- test/test_schedule.py | 2 +- test/test_site.py | 3 +- 10 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 samples/materialize_workbooks.py diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py new file mode 100644 index 000000000..8434c0031 --- /dev/null +++ b/samples/materialize_workbooks.py @@ -0,0 +1,105 @@ +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Materialized views settings for sites/workbooks.') + parser.add_argument('--server', '-s', required=True, help='Tableau server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--password', '-p', required=False, help='password to sign into server') + parser.add_argument('--mode', '-m', required=False, choices=['enable', 'disable'], + help='enable/disable materialized views for sites/workbooks') + parser.add_argument('--status', '-st', required=False, action='store_true', + help='show materialized views enabled sites/workbooks') + parser.add_argument('--site-id', '-si', required=False, + help='set to Default site by default') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook'], + help='type of content you want to update materialized views settings on') + + args = parser.parse_args() + + if args.password: + password = args.password + else: + password = getpass.getpass("Password: ") + + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # site content url is the TSC term for site id + site_content_url = args.site_id if args.site_id is not None else "" + enable_materialized_views = args.mode == "enable" + + if (args.type is None) != (args.mode is None): + print("Use '--type --mode ' to update materialized views settings.") + return + + if args.type == 'site': + update_site(args, enable_materialized_views, password, site_content_url) + + elif args.type == 'workbook': + update_workbook(args, enable_materialized_views, password, site_content_url) + + if args.status: + show_materialized_views_status(args, password, site_content_url) + + +def show_materialized_views_status(args, password, site_content_url): + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server) + enabled_sites = set() + with server.auth.sign_in(tableau_auth): + # For server admin, this will prints all the materialized views enabled sites + # For other users, this only prints the status of the site they belong to + print("Materialized views is enabled on sites:") + for site in TSC.Pager(server.sites): + if site.materialized_views_enabled: + enabled_sites.add(site) + print "Site name:", site.name + print + print("Materialized views is enabled on workbooks:") + # Individual workbooks can be enabled only when the sites they belong to are enabled too + for site in enabled_sites: + site_auth = TSC.TableauAuth(args.username, password, site.content_url) + with server.auth.sign_in(site_auth): + for workbook in TSC.Pager(server.workbooks): + if workbook.materialized_views_enabled: + print "Workbook:", workbook.name, "from site:", site.name + + +def update_workbook(args, enable_materialized_views, password, site_content_url): + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server) + # Now it updates all the workbooks in the site + # To update selected ones please use filter: + # https://github.com/tableau/server-client-python/blob/master/docs/docs/filter-sort.md + # This only updates the workbooks in the site you are signing into + with server.auth.sign_in(tableau_auth): + for workbook in TSC.Pager(server.workbooks): + workbook.materialized_views_enabled = enable_materialized_views + + server.workbooks.update(workbook) + site = server.sites.get_by_content_url(site_content_url) + print "Updated materialized views settings for workbook:", workbook.name, "from site:", site.name + print + + +def update_site(args, enable_materialized_views, password, site_content_url): + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) + server = TSC.Server(args.server) + with server.auth.sign_in(tableau_auth): + site_to_update = server.sites.get_by_content_url(site_content_url) + site_to_update.materialized_views_enabled = enable_materialized_views + + server.sites.update(site_to_update) + print "Updated materialized views settings for site:", site_to_update.name + print + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 6ee64e227..4be047430 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None): + revision_limit=None, materialized_views_enabled=False): self._admin_mode = None self._id = None self._num_users = None @@ -33,6 +33,7 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.revision_history_enabled = revision_history_enabled self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode + self.materialized_views_enabled = materialized_views_enabled @property def admin_mode(self): @@ -123,6 +124,15 @@ def subscribe_others_enabled(self): def subscribe_others_enabled(self, value): self._subscribe_others_enabled = value + @property + def materialized_views_enabled(self): + return self._materialized_views_enabled + + @materialized_views_enabled.setter + @property_is_boolean + def materialized_views_enabled(self, value): + self._materialized_views_enabled = value + def is_default(self): return self.name.lower() == 'default' @@ -132,16 +142,17 @@ def _parse_common_tags(self, site_xml, ns): if site_xml is not None: (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage) = self._parse_element(site_xml, ns) + user_quota, storage_quota, revision_limit, num_users, storage, + materialized_views_enabled) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage) + revision_limit, num_users, storage, materialized_views_enabled) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage): + user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_enabled): if id is not None: self._id = id if name: @@ -170,6 +181,8 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._num_users = num_users if storage: self._storage = storage + if materialized_views_enabled: + self._materialized_views_enabled = materialized_views_enabled @classmethod def from_response(cls, resp, ns): @@ -179,12 +192,13 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, materialized_views_enabled) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage) + user_quota, storage_quota, revision_limit, num_users, storage, + materialized_views_enabled) all_site_items.append(site_item) return all_site_items @@ -219,9 +233,11 @@ def _parse_element(site_xml, ns): num_users = usage_elem.get('numUsers', None) storage = usage_elem.get('storage', None) + materialized_views_enabled = string_to_bool(site_xml.get('materializedViewsEnabled', '')) + return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage + revision_limit, num_users, storage, materialized_views_enabled # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index bcf13b9ac..17cad293a 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -24,6 +24,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() + self.materialized_views_enabled = None @property def connections(self): @@ -112,15 +113,18 @@ def _parse_common_tags(self, workbook_xml, ns): workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) if workbook_xml is not None: (_, _, _, _, updated_at, _, show_tabs, - project_id, project_name, owner_id, _, _) = self._parse_element(workbook_xml, ns) + project_id, project_name, owner_id, _, _, + materialized_views_enabled) = self._parse_element(workbook_xml, ns) self._set_values(None, None, None, None, updated_at, - None, show_tabs, project_id, project_name, owner_id, None, None) + None, show_tabs, project_id, project_name, owner_id, None, None, + materialized_views_enabled) return self def _set_values(self, id, name, content_url, created_at, updated_at, - size, show_tabs, project_id, project_name, owner_id, tags, views): + size, show_tabs, project_id, project_name, owner_id, tags, views, + materialized_views_enabled): if id is not None: self._id = id if name: @@ -146,6 +150,8 @@ def _set_values(self, id, name, content_url, created_at, updated_at, self._initial_tags = copy.copy(tags) if views: self._views = views + if materialized_views_enabled is not None: + self.materialized_views_enabled = materialized_views_enabled @classmethod def from_response(cls, resp, ns): @@ -154,11 +160,13 @@ def from_response(cls, resp, ns): all_workbook_xml = parsed_response.findall('.//t:workbook', namespaces=ns) for workbook_xml in all_workbook_xml: (id, name, content_url, created_at, updated_at, size, show_tabs, - project_id, project_name, owner_id, tags, views) = cls._parse_element(workbook_xml, ns) + project_id, project_name, owner_id, tags, views, + materialized_views_enabled) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) workbook_item._set_values(id, name, content_url, created_at, updated_at, - size, show_tabs, None, project_name, owner_id, tags, views) + size, show_tabs, None, project_name, owner_id, tags, views, + materialized_views_enabled) all_workbook_items.append(workbook_item) return all_workbook_items @@ -199,8 +207,10 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + materialized_views_enabled = string_to_bool(workbook_xml.get('materializedViewsEnabled', '')) + return id, name, content_url, created_at, updated_at, size, show_tabs,\ - project_id, project_name, owner_id, tags, views + project_id, project_name, owner_id, tags, views, materialized_views_enabled # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 81b782c05..f0f004cc3 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -44,8 +44,19 @@ def get_by_name(self, site_name): server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Gets 1 site by content url + @api(version="3.3") + def get_by_content_url(self, content_url): + if content_url is None: + error = "Content URL undefined." + raise ValueError(error) + logger.info('Querying single site (Content URL: {0})'.format(content_url)) + url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + server_response = self.get_request(url) + return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Update site - @api(version="2.0") + @api(version="3.3") def update(self, site_item): if not site_item.id: error = "Site item missing ID." diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e4d7da466..78588a9e7 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -31,7 +31,7 @@ def baseurl(self): return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site - @api(version="2.0") + @api(version="3.3") def get(self, req_options=None): logger.info('Querying all workbooks on site') url = self.baseurl @@ -41,7 +41,7 @@ def get(self, req_options=None): return all_workbook_items, pagination_item # Get 1 workbook - @api(version="2.0") + @api(version="3.3") def get_by_id(self, workbook_id): if not workbook_id: error = "Workbook ID undefined." @@ -70,7 +70,7 @@ def delete(self, workbook_id): logger.info('Deleted single workbook (ID: {0})'.format(workbook_id)) # Update workbook - @api(version="2.0") + @api(version="3.3") def update(self, workbook_item): if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 48a33005e..6bba8b898 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -282,14 +282,16 @@ def update_req(self, site_item): site_element.attrib['state'] = site_item.state if site_item.storage_quota: site_element.attrib['storageQuota'] = str(site_item.storage_quota) - if site_item.disable_subscriptions: + if site_item.disable_subscriptions is not None: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() - if site_item.subscribe_others_enabled: + if site_item.subscribe_others_enabled is not None: site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: site_element.attrib['revisionLimit'] = str(site_item.revision_limit) - if site_item.subscribe_others_enabled: + if site_item.revision_history_enabled is not None: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() + if site_item.materialized_views_enabled is not None: + site_element.attrib['materializedViewsEnabled'] = str(site_item.materialized_views_enabled).lower() return ET.tostring(xml_request) def create_req(self, site_item): @@ -380,6 +382,8 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id + if workbook_item.materialized_views_enabled is not None: + workbook_element.attrib['materializedViewsEnabled'] = str(workbook_item.materialized_views_enabled).lower() return ET.tostring(xml_request) def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 95ee564ee..ce62ef0c7 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -36,7 +36,7 @@ def __init__(self, server_address, use_server_version=False): self._session = requests.Session() self._http_options = dict() - self.version = "2.3" + self.version = "3.3" self.auth = Auth(self) self.views = Views(self) self.users = Users(self) diff --git a/test/test_group.py b/test/test_group.py index 7096ca408..fdd7ef6eb 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -123,7 +123,7 @@ def test_add_user_before_populating(self): add_user_response = f.read().decode('utf-8') with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) - m.post('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' + m.post('http://test/api/3.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' '-63f5805dbe3c/users', text=add_user_response) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] @@ -151,7 +151,7 @@ def test_remove_user_before_populating(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - m.delete('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' + m.delete('http://test/api/3.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' '-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', text='ok') all_groups, pagination_item = self.server.groups.get() diff --git a/test/test_schedule.py b/test/test_schedule.py index a9ae9bb67..63dc5e64c 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -201,7 +201,7 @@ def test_add_workbook(self): self.assertEqual(0, len(result), "Added properly") def test_add_datasource(self): - self.server.version = "2.8" + self.server.version = "3.3" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: diff --git a/test/test_site.py b/test/test_site.py index 8113613ca..21f44a69d 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -91,7 +91,8 @@ def test_update(self): single_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, - disable_subscriptions=True, revision_history_enabled=False) + disable_subscriptions=True, revision_history_enabled=False, + materialized_views_enabled=False) single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) From 75824bedf966a6b2937bb6f3d1741d3bd0c59957 Mon Sep 17 00:00:00 2001 From: bzhang Date: Fri, 18 Jan 2019 09:42:19 -0800 Subject: [PATCH 038/567] fixed failed test --- test/test_schedule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_schedule.py b/test/test_schedule.py index 63dc5e64c..83f1fe1a2 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -187,7 +187,7 @@ def test_update(self): single_schedule.interval_item.interval) def test_add_workbook(self): - self.server.version = "2.8" + self.server.version = "3.3" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: @@ -201,7 +201,7 @@ def test_add_workbook(self): self.assertEqual(0, len(result), "Added properly") def test_add_datasource(self): - self.server.version = "3.3" + self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: From a6c249b72bed9ea64ebe21576988b5fb5ca1c977 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 18 Jan 2019 09:59:48 -0800 Subject: [PATCH 039/567] Fixing import of TSC.Target to client access (#377) * fixing import of TSC.Target to client access --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + test/assets/subscription_create.xml | 8 ++++++++ test/test_subscription.py | 24 ++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 test/assets/subscription_create.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 3f2970281..85972d48b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,7 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem + SubscriptionItem, Target 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 710831e07..63a861cbb 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -11,6 +11,7 @@ from .server_info_item import ServerInfoItem from .site_item import SiteItem from .tableau_auth import TableauAuth +from .target import Target from .task_item import TaskItem from .user_item import UserItem from .view_item import ViewItem diff --git a/test/assets/subscription_create.xml b/test/assets/subscription_create.xml new file mode 100644 index 000000000..48f391416 --- /dev/null +++ b/test/assets/subscription_create.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/test_subscription.py b/test/test_subscription.py index 50fc7046f..2e4b1eadf 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -5,6 +5,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +CREATE_XML = os.path.join(TEST_ASSET_DIR, "subscription_create.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "subscription_get.xml") GET_XML_BY_ID = os.path.join(TEST_ASSET_DIR, "subscription_get_by_id.xml") @@ -48,3 +49,26 @@ def test_get_subscription_by_id(self): self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) self.assertEqual('Not Found Alert', subscription.subject) self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) + + def test_create_subscription(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) + + target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") + new_subscription = TSC.SubscriptionItem("subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", + "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item) + new_subscription = self.server.subscriptions.create(new_subscription) + + self.assertEqual("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", new_subscription.id) + self.assertEqual("sub_name", new_subscription.subject) + self.assertEqual("960e61f2-1838-40b2-bba2-340c9492f943", new_subscription.target.id) + self.assertEqual("Workbook", new_subscription.target.type) + self.assertEqual("4906c453-d5ec-4972-9ff4-789b629bdfa2", new_subscription.schedule_id) + self.assertEqual("8d30c8de-0a5f-4bee-b266-c621b4f3eed0", new_subscription.user_id) + + def test_delete_subscription(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc', status_code=204) + self.server.subscriptions.delete('78e9318d-2d29-4d67-b60f-3f2f5fd89ecc') From 3d196dd46a9f03a0ed7c91a358fbde61b0029ca1 Mon Sep 17 00:00:00 2001 From: bzhang Date: Fri, 18 Jan 2019 15:43:11 -0800 Subject: [PATCH 040/567] reverted some changes that does not affect my code, will submit issues instead --- tableauserverclient/server/request_factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6bba8b898..d9d40951b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -282,13 +282,13 @@ def update_req(self, site_item): site_element.attrib['state'] = site_item.state if site_item.storage_quota: site_element.attrib['storageQuota'] = str(site_item.storage_quota) - if site_item.disable_subscriptions is not None: + if site_item.disable_subscriptions: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() - if site_item.subscribe_others_enabled is not None: + if site_item.subscribe_others_enabled: site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: site_element.attrib['revisionLimit'] = str(site_item.revision_limit) - if site_item.revision_history_enabled is not None: + if site_item.subscribe_others_enabled: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() if site_item.materialized_views_enabled is not None: site_element.attrib['materializedViewsEnabled'] = str(site_item.materialized_views_enabled).lower() From 9de506398973025ce71cd6881bd9e32730c5f8b5 Mon Sep 17 00:00:00 2001 From: bzhang Date: Mon, 21 Jan 2019 23:04:14 -0800 Subject: [PATCH 041/567] changed default version back and use auto-detect server version --- samples/materialize_workbooks.py | 15 ++++++++------- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 6 +++--- tableauserverclient/server/server.py | 2 +- test/test_group.py | 4 ++-- test/test_schedule.py | 2 +- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 8434c0031..3bb8eb910 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -51,17 +51,18 @@ def main(): def show_materialized_views_status(args, password, site_content_url): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server) + server = TSC.Server(args.server, use_server_version=True) enabled_sites = set() with server.auth.sign_in(tableau_auth): # For server admin, this will prints all the materialized views enabled sites # For other users, this only prints the status of the site they belong to - print("Materialized views is enabled on sites:") + print "Materialized views is enabled on sites:" for site in TSC.Pager(server.sites): if site.materialized_views_enabled: enabled_sites.add(site) print "Site name:", site.name - print + print '\n' + print("Materialized views is enabled on workbooks:") # Individual workbooks can be enabled only when the sites they belong to are enabled too for site in enabled_sites: @@ -74,7 +75,7 @@ def show_materialized_views_status(args, password, site_content_url): def update_workbook(args, enable_materialized_views, password, site_content_url): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server) + server = TSC.Server(args.server, use_server_version=True) # Now it updates all the workbooks in the site # To update selected ones please use filter: # https://github.com/tableau/server-client-python/blob/master/docs/docs/filter-sort.md @@ -86,19 +87,19 @@ def update_workbook(args, enable_materialized_views, password, site_content_url) server.workbooks.update(workbook) site = server.sites.get_by_content_url(site_content_url) print "Updated materialized views settings for workbook:", workbook.name, "from site:", site.name - print + print '\n' def update_site(args, enable_materialized_views, password, site_content_url): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): site_to_update = server.sites.get_by_content_url(site_content_url) site_to_update.materialized_views_enabled = enable_materialized_views server.sites.update(site_to_update) print "Updated materialized views settings for site:", site_to_update.name - print + print '\n' if __name__ == "__main__": diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index f0f004cc3..d0938ecae 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -56,7 +56,7 @@ def get_by_content_url(self, content_url): return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Update site - @api(version="3.3") + @api(version="2.0") def update(self, site_item): if not site_item.id: error = "Site item missing ID." diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 78588a9e7..e4d7da466 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -31,7 +31,7 @@ def baseurl(self): return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site - @api(version="3.3") + @api(version="2.0") def get(self, req_options=None): logger.info('Querying all workbooks on site') url = self.baseurl @@ -41,7 +41,7 @@ def get(self, req_options=None): return all_workbook_items, pagination_item # Get 1 workbook - @api(version="3.3") + @api(version="2.0") def get_by_id(self, workbook_id): if not workbook_id: error = "Workbook ID undefined." @@ -70,7 +70,7 @@ def delete(self, workbook_id): logger.info('Deleted single workbook (ID: {0})'.format(workbook_id)) # Update workbook - @api(version="3.3") + @api(version="2.0") def update(self, workbook_item): if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ce62ef0c7..95ee564ee 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -36,7 +36,7 @@ def __init__(self, server_address, use_server_version=False): self._session = requests.Session() self._http_options = dict() - self.version = "3.3" + self.version = "2.3" self.auth = Auth(self) self.views = Views(self) self.users = Users(self) diff --git a/test/test_group.py b/test/test_group.py index fdd7ef6eb..7096ca408 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -123,7 +123,7 @@ def test_add_user_before_populating(self): add_user_response = f.read().decode('utf-8') with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) - m.post('http://test/api/3.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' + m.post('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' '-63f5805dbe3c/users', text=add_user_response) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] @@ -151,7 +151,7 @@ def test_remove_user_before_populating(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - m.delete('http://test/api/3.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' + m.delete('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' '-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', text='ok') all_groups, pagination_item = self.server.groups.get() diff --git a/test/test_schedule.py b/test/test_schedule.py index 83f1fe1a2..a9ae9bb67 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -187,7 +187,7 @@ def test_update(self): single_schedule.interval_item.interval) def test_add_workbook(self): - self.server.version = "3.3" + self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: From dd8bad172347179d17d5c74bc5b9d378e4ed163d Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 01:08:33 -0800 Subject: [PATCH 042/567] enable/disable by project --- samples/materialize_workbooks.py | 100 ++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 3bb8eb910..7bde56bfe 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -18,8 +18,12 @@ def main(): help='set to Default site by default') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook'], + parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', + 'project_id', 'project_path'], help='type of content you want to update materialized views settings on') + parser.add_argument('--project-name', '-pn', required=False, help='name of the project') + parser.add_argument('--project-id', '-pi', required=False, help="id of the project") + parser.add_argument('--project-path', '-pp', required =False, help="path of the project") args = parser.parse_args() @@ -36,7 +40,7 @@ def main(): enable_materialized_views = args.mode == "enable" if (args.type is None) != (args.mode is None): - print("Use '--type --mode ' to update materialized views settings.") + print "Use '--type --mode ' to update materialized views settings." return if args.type == 'site': @@ -45,10 +49,42 @@ def main(): elif args.type == 'workbook': update_workbook(args, enable_materialized_views, password, site_content_url) + elif args.type == 'project_name': + update_project_by_name(args, enable_materialized_views, password, site_content_url) + + elif args.type == 'project_id': + update_project_by_id(args, enable_materialized_views, password, site_content_url) + + elif args.type == 'project_path': + update_project_by_path(args, enable_materialized_views, password, site_content_url) + if args.status: show_materialized_views_status(args, password, site_content_url) +def find_project_path(project, all_projects, path): + path = project.name + '/' + path + if project.parent_id is None: + return path + else: + find_project_path(all_projects[project.parent_id], all_projects, path) + + +def get_project_paths(server, projects): + # most likely user won't have too many projects so we store them in a dict to search + all_projects = {project.id: project for project in TSC.Pager(server.projects)} + + result = dict() + for project in projects: + result[find_project_path(project, all_projects, "")] = project + return result + + +def print_paths(paths): + for path in paths.keys(): + print path + + def show_materialized_views_status(args, password, site_content_url): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) @@ -73,6 +109,66 @@ def show_materialized_views_status(args, password, site_content_url): print "Workbook:", workbook.name, "from site:", site.name +def update_project_by_path(args, enable_materialized_views, password, site_content_url): + if args.project_path is None: + print "Use --project_path to specify the path of the project" + return + tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) + server = TSC.Server(args.server, use_server_version=True) + project_name = args.project_path.split('/') + with server.auth.sign_in(tableau_auth): + projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] + + if len(projects) > 1: + possible_paths = get_project_paths(server.projects) + update_project(possible_paths[args.project_path], server, enable_materialized_views) + else: + update_project(projects[0], server, enable_materialized_views) + + +def update_project_by_id(args, enable_materialized_views, password, site_content_url): + if args.project_id is None: + print "Use --project-id to specify the id of the project" + return + tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + for project in TSC.Pager(server.projects): + if project.id == args.project_id: + update_project(project, server, enable_materialized_views) + break + + +def update_project_by_name(args, enable_materialized_views, password, site_content_url): + if args.project_name is None: + print "Use --project-name to specify the name of the project" + return + tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # get all projects with given name + projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] + + if len(projects) > 1: + possible_paths = get_project_paths(server, projects) + print "Project name is not unique, use '--project_path ' or '--project-id '" + print "Possible project paths:" + print_paths(possible_paths) + print '\n' + return + else: + update_project(projects[0], server, enable_materialized_views) + + +def update_project(project, server, enable_materialized_views): + for workbook in TSC.Pager(server.workbooks): + if workbook.project_id == project.id: + workbook.materialized_views_enabled = enable_materialized_views + server.workbooks.update(workbook) + print "Updated materialized views settings for project:", project.name + print '\n' + + def update_workbook(args, enable_materialized_views, password, site_content_url): tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) From 749551cd75df4e4cd8b58383621acf76a30fb531 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 03:03:09 -0800 Subject: [PATCH 043/567] removed update project by id, added update workbooks by list (txt) --- samples/materialize_workbooks.py | 84 +++++++++++++++++++------------- samples/test.txt | 2 + 2 files changed, 51 insertions(+), 35 deletions(-) create mode 100644 samples/test.txt diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 7bde56bfe..18eb978de 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -1,8 +1,8 @@ import argparse import getpass import logging - import tableauserverclient as TSC +from collections import defaultdict def main(): @@ -21,8 +21,8 @@ def main(): parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', 'project_id', 'project_path'], help='type of content you want to update materialized views settings on') + parser.add_argument('--file-path', '-fp', required=False, help='path to a list of workbooks') parser.add_argument('--project-name', '-pn', required=False, help='name of the project') - parser.add_argument('--project-id', '-pi', required=False, help="id of the project") parser.add_argument('--project-path', '-pp', required =False, help="path of the project") args = parser.parse_args() @@ -52,9 +52,6 @@ def main(): elif args.type == 'project_name': update_project_by_name(args, enable_materialized_views, password, site_content_url) - elif args.type == 'project_id': - update_project_by_id(args, enable_materialized_views, password, site_content_url) - elif args.type == 'project_path': update_project_by_path(args, enable_materialized_views, password, site_content_url) @@ -67,7 +64,7 @@ def find_project_path(project, all_projects, path): if project.parent_id is None: return path else: - find_project_path(all_projects[project.parent_id], all_projects, path) + return find_project_path(all_projects[project.parent_id], all_projects, path) def get_project_paths(server, projects): @@ -76,7 +73,7 @@ def get_project_paths(server, projects): result = dict() for project in projects: - result[find_project_path(project, all_projects, "")] = project + result[find_project_path(project, all_projects, "")[:-1]] = project return result @@ -99,7 +96,7 @@ def show_materialized_views_status(args, password, site_content_url): print "Site name:", site.name print '\n' - print("Materialized views is enabled on workbooks:") + print "Materialized views is enabled on workbooks:" # Individual workbooks can be enabled only when the sites they belong to are enabled too for site in enabled_sites: site_auth = TSC.TableauAuth(args.username, password, site.content_url) @@ -115,30 +112,17 @@ def update_project_by_path(args, enable_materialized_views, password, site_conte return tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) - project_name = args.project_path.split('/') + project_name = args.project_path.split('/')[-1] with server.auth.sign_in(tableau_auth): projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] if len(projects) > 1: - possible_paths = get_project_paths(server.projects) + possible_paths = get_project_paths(server, projects) update_project(possible_paths[args.project_path], server, enable_materialized_views) else: update_project(projects[0], server, enable_materialized_views) -def update_project_by_id(args, enable_materialized_views, password, site_content_url): - if args.project_id is None: - print "Use --project-id to specify the id of the project" - return - tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - for project in TSC.Pager(server.projects): - if project.id == args.project_id: - update_project(project, server, enable_materialized_views) - break - - def update_project_by_name(args, enable_materialized_views, password, site_content_url): if args.project_name is None: print "Use --project-name to specify the name of the project" @@ -151,7 +135,7 @@ def update_project_by_name(args, enable_materialized_views, password, site_conte if len(projects) > 1: possible_paths = get_project_paths(server, projects) - print "Project name is not unique, use '--project_path ' or '--project-id '" + print "Project name is not unique, use '--project_path '" print "Possible project paths:" print_paths(possible_paths) print '\n' @@ -169,21 +153,51 @@ def update_project(project, server, enable_materialized_views): print '\n' +def parse_workbook_path(file_path): + workbook_paths = open(file_path, 'r') + workbook_path_mapping = defaultdict(list) + for workbook_path in workbook_paths: + workbook_project = workbook_path.rstrip().split('/') + workbook_path_mapping[workbook_project[-1]].append('/'.join(workbook_project[:-1])) + return workbook_path_mapping + + def update_workbook(args, enable_materialized_views, password, site_content_url): + if args.file_path is None: + print "Use '--file-path ' to specify the path of a list of workbooks" + print "In the file, each line is a path to a workbook, for example:" + print "project1/project2/workbook_name_1" + print "project3/workbook_name_2" + print '\n' + return + tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) - # Now it updates all the workbooks in the site - # To update selected ones please use filter: - # https://github.com/tableau/server-client-python/blob/master/docs/docs/filter-sort.md - # This only updates the workbooks in the site you are signing into with server.auth.sign_in(tableau_auth): - for workbook in TSC.Pager(server.workbooks): - workbook.materialized_views_enabled = enable_materialized_views - - server.workbooks.update(workbook) - site = server.sites.get_by_content_url(site_content_url) - print "Updated materialized views settings for workbook:", workbook.name, "from site:", site.name - print '\n' + workbook_path_mapping = parse_workbook_path(args.file_path) + all_projects = {project.id: project for project in TSC.Pager(server.projects)} + update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping) + + +def update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping): + for workbook_name, workbook_paths in workbook_path_mapping.items(): + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + workbook_name)) + workbooks = list(TSC.Pager(server.workbooks, req_option)) + if len(workbooks) == 1: + workbooks[0].materialized_views_enabled = enable_materialized_views + server.workbooks.update(workbooks[0]) + print "Updated materialized views settings for workbook:", workbooks[0].name + else: + for workbook in workbooks: + path = find_project_path(all_projects[workbook.project_id], all_projects, "")[:-1] + if path in workbook_paths: + workbook.materialized_views_enabled = enable_materialized_views + server.workbooks.update(workbook) + print "Updated materialized views settings for workbook:", path + '/' + workbook.name + print '\n' def update_site(args, enable_materialized_views, password, site_content_url): diff --git a/samples/test.txt b/samples/test.txt new file mode 100644 index 000000000..06104799f --- /dev/null +++ b/samples/test.txt @@ -0,0 +1,2 @@ +project1/project1/Book3 +project1/Book3 \ No newline at end of file From 2cb93fe462cb8137483f47c49e0da0f18dbb6e55 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 03:12:49 -0800 Subject: [PATCH 044/567] fixed failed test --- samples/materialize_workbooks.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 18eb978de..ca319ebc2 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -23,7 +23,7 @@ def main(): help='type of content you want to update materialized views settings on') parser.add_argument('--file-path', '-fp', required=False, help='path to a list of workbooks') parser.add_argument('--project-name', '-pn', required=False, help='name of the project') - parser.add_argument('--project-path', '-pp', required =False, help="path of the project") + parser.add_argument('--project-path', '-pp', required=False, help="path of the project") args = parser.parse_args() @@ -60,7 +60,11 @@ def main(): def find_project_path(project, all_projects, path): - path = project.name + '/' + path + path = project.name if len(path) == 0 else project.name + '/' + path + # if len(path) == 0: + # path = project.name + # else: + # path = project.name + '/' + path if project.parent_id is None: return path else: @@ -73,7 +77,7 @@ def get_project_paths(server, projects): result = dict() for project in projects: - result[find_project_path(project, all_projects, "")[:-1]] = project + result[find_project_path(project, all_projects, "")] = project return result @@ -192,7 +196,7 @@ def update_workbooks_by_paths(all_projects, enable_materialized_views, server, w print "Updated materialized views settings for workbook:", workbooks[0].name else: for workbook in workbooks: - path = find_project_path(all_projects[workbook.project_id], all_projects, "")[:-1] + path = find_project_path(all_projects[workbook.project_id], all_projects, "") if path in workbook_paths: workbook.materialized_views_enabled = enable_materialized_views server.workbooks.update(workbook) From 515b3de438a6cc1cbf5d7db3843d06cc209f9132 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 04:16:48 -0800 Subject: [PATCH 045/567] fixed the issue of print in python 3 --- samples/materialize_workbooks.py | 51 ++++++++++++++------------------ 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index ca319ebc2..ae9a9027f 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -40,7 +40,7 @@ def main(): enable_materialized_views = args.mode == "enable" if (args.type is None) != (args.mode is None): - print "Use '--type --mode ' to update materialized views settings." + print("Use '--type --mode ' to update materialized views settings.") return if args.type == 'site': @@ -61,10 +61,7 @@ def main(): def find_project_path(project, all_projects, path): path = project.name if len(path) == 0 else project.name + '/' + path - # if len(path) == 0: - # path = project.name - # else: - # path = project.name + '/' + path + if project.parent_id is None: return path else: @@ -83,7 +80,7 @@ def get_project_paths(server, projects): def print_paths(paths): for path in paths.keys(): - print path + print(path) def show_materialized_views_status(args, password, site_content_url): @@ -93,26 +90,26 @@ def show_materialized_views_status(args, password, site_content_url): with server.auth.sign_in(tableau_auth): # For server admin, this will prints all the materialized views enabled sites # For other users, this only prints the status of the site they belong to - print "Materialized views is enabled on sites:" + print("Materialized views is enabled on sites:") for site in TSC.Pager(server.sites): if site.materialized_views_enabled: enabled_sites.add(site) - print "Site name:", site.name - print '\n' + print("Site name: {}".format(site.name)) + print('\n') - print "Materialized views is enabled on workbooks:" + print("Materialized views is enabled on workbooks:") # Individual workbooks can be enabled only when the sites they belong to are enabled too for site in enabled_sites: site_auth = TSC.TableauAuth(args.username, password, site.content_url) with server.auth.sign_in(site_auth): for workbook in TSC.Pager(server.workbooks): if workbook.materialized_views_enabled: - print "Workbook:", workbook.name, "from site:", site.name + print("Workbook: {} from site: {}".format(workbook.name, site.name)) def update_project_by_path(args, enable_materialized_views, password, site_content_url): if args.project_path is None: - print "Use --project_path to specify the path of the project" + print("Use --project_path to specify the path of the project") return tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) @@ -129,7 +126,7 @@ def update_project_by_path(args, enable_materialized_views, password, site_conte def update_project_by_name(args, enable_materialized_views, password, site_content_url): if args.project_name is None: - print "Use --project-name to specify the name of the project" + print("Use --project-name to specify the name of the project") return tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) @@ -139,10 +136,10 @@ def update_project_by_name(args, enable_materialized_views, password, site_conte if len(projects) > 1: possible_paths = get_project_paths(server, projects) - print "Project name is not unique, use '--project_path '" - print "Possible project paths:" + print("Project name is not unique, use '--project_path '") + print("Possible project paths:") print_paths(possible_paths) - print '\n' + print('\n') return else: update_project(projects[0], server, enable_materialized_views) @@ -153,8 +150,9 @@ def update_project(project, server, enable_materialized_views): if workbook.project_id == project.id: workbook.materialized_views_enabled = enable_materialized_views server.workbooks.update(workbook) - print "Updated materialized views settings for project:", project.name - print '\n' + + print("Updated materialized views settings for project: {}".format(project.name)) + print('\n') def parse_workbook_path(file_path): @@ -168,11 +166,8 @@ def parse_workbook_path(file_path): def update_workbook(args, enable_materialized_views, password, site_content_url): if args.file_path is None: - print "Use '--file-path ' to specify the path of a list of workbooks" - print "In the file, each line is a path to a workbook, for example:" - print "project1/project2/workbook_name_1" - print "project3/workbook_name_2" - print '\n' + print("Use '--file-path ' to specify the path of a list of workbooks") + print('\n') return tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) @@ -193,15 +188,15 @@ def update_workbooks_by_paths(all_projects, enable_materialized_views, server, w if len(workbooks) == 1: workbooks[0].materialized_views_enabled = enable_materialized_views server.workbooks.update(workbooks[0]) - print "Updated materialized views settings for workbook:", workbooks[0].name + print("Updated materialized views settings for workbook: {}".format(workbooks[0].name)) else: for workbook in workbooks: path = find_project_path(all_projects[workbook.project_id], all_projects, "") if path in workbook_paths: workbook.materialized_views_enabled = enable_materialized_views server.workbooks.update(workbook) - print "Updated materialized views settings for workbook:", path + '/' + workbook.name - print '\n' + print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) + print('\n') def update_site(args, enable_materialized_views, password, site_content_url): @@ -212,8 +207,8 @@ def update_site(args, enable_materialized_views, password, site_content_url): site_to_update.materialized_views_enabled = enable_materialized_views server.sites.update(site_to_update) - print "Updated materialized views settings for site:", site_to_update.name - print '\n' + print("Updated materialized views settings for site: {}".format(site_to_update.name)) + print('\n') if __name__ == "__main__": From 8f576462a929193b453397afd8c96a386a9e8dc2 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 04:20:57 -0800 Subject: [PATCH 046/567] fixed failed test --- samples/materialize_workbooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index ae9a9027f..f1d527e0d 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -61,7 +61,7 @@ def main(): def find_project_path(project, all_projects, path): path = project.name if len(path) == 0 else project.name + '/' + path - + if project.parent_id is None: return path else: From e90316104608cdeaf2557dfe8732d94efd91f9fc Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 13:41:27 -0800 Subject: [PATCH 047/567] now we always check if the workbook/project's path is correct when path is provided --- samples/materialize_workbooks.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index f1d527e0d..8ad64cffd 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -117,11 +117,8 @@ def update_project_by_path(args, enable_materialized_views, password, site_conte with server.auth.sign_in(tableau_auth): projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] - if len(projects) > 1: - possible_paths = get_project_paths(server, projects) - update_project(possible_paths[args.project_path], server, enable_materialized_views) - else: - update_project(projects[0], server, enable_materialized_views) + possible_paths = get_project_paths(server, projects) + update_project(possible_paths[args.project_path], server, enable_materialized_views) def update_project_by_name(args, enable_materialized_views, password, site_content_url): @@ -185,17 +182,12 @@ def update_workbooks_by_paths(all_projects, enable_materialized_views, server, w TSC.RequestOptions.Operator.Equals, workbook_name)) workbooks = list(TSC.Pager(server.workbooks, req_option)) - if len(workbooks) == 1: - workbooks[0].materialized_views_enabled = enable_materialized_views - server.workbooks.update(workbooks[0]) - print("Updated materialized views settings for workbook: {}".format(workbooks[0].name)) - else: - for workbook in workbooks: - path = find_project_path(all_projects[workbook.project_id], all_projects, "") - if path in workbook_paths: - workbook.materialized_views_enabled = enable_materialized_views - server.workbooks.update(workbook) - print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) + for workbook in workbooks: + path = find_project_path(all_projects[workbook.project_id], all_projects, "") + if path in workbook_paths: + workbook.materialized_views_enabled = enable_materialized_views + server.workbooks.update(workbook) + print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) print('\n') From 56424f5373a4eef87317ac90be67e08bac0112ce Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 14:22:36 -0800 Subject: [PATCH 048/567] refactoring --- samples/materialize_workbooks.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 8ad64cffd..13fbb6bc0 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -44,16 +44,20 @@ def main(): return if args.type == 'site': - update_site(args, enable_materialized_views, password, site_content_url) + if not update_site(args, enable_materialized_views, password, site_content_url): + return elif args.type == 'workbook': - update_workbook(args, enable_materialized_views, password, site_content_url) + if not update_workbook(args, enable_materialized_views, password, site_content_url): + return elif args.type == 'project_name': - update_project_by_name(args, enable_materialized_views, password, site_content_url) + if not update_project_by_name(args, enable_materialized_views, password, site_content_url): + return elif args.type == 'project_path': - update_project_by_path(args, enable_materialized_views, password, site_content_url) + if not update_project_by_path(args, enable_materialized_views, password, site_content_url): + return if args.status: show_materialized_views_status(args, password, site_content_url) @@ -110,7 +114,7 @@ def show_materialized_views_status(args, password, site_content_url): def update_project_by_path(args, enable_materialized_views, password, site_content_url): if args.project_path is None: print("Use --project_path to specify the path of the project") - return + return False tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) project_name = args.project_path.split('/')[-1] @@ -119,12 +123,13 @@ def update_project_by_path(args, enable_materialized_views, password, site_conte possible_paths = get_project_paths(server, projects) update_project(possible_paths[args.project_path], server, enable_materialized_views) + return True def update_project_by_name(args, enable_materialized_views, password, site_content_url): if args.project_name is None: print("Use --project-name to specify the name of the project") - return + return False tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): @@ -137,9 +142,10 @@ def update_project_by_name(args, enable_materialized_views, password, site_conte print("Possible project paths:") print_paths(possible_paths) print('\n') - return + return False else: update_project(projects[0], server, enable_materialized_views) + return True def update_project(project, server, enable_materialized_views): @@ -165,14 +171,14 @@ def update_workbook(args, enable_materialized_views, password, site_content_url) if args.file_path is None: print("Use '--file-path ' to specify the path of a list of workbooks") print('\n') - return - + return False tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): workbook_path_mapping = parse_workbook_path(args.file_path) all_projects = {project.id: project for project in TSC.Pager(server.projects)} update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping) + return True def update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping): @@ -201,6 +207,7 @@ def update_site(args, enable_materialized_views, password, site_content_url): server.sites.update(site_to_update) print("Updated materialized views settings for site: {}".format(site_to_update.name)) print('\n') + return True if __name__ == "__main__": From 47c1cd61062c75881280126da3c742f6fa26ea66 Mon Sep 17 00:00:00 2001 From: Bruce Zhang Date: Tue, 22 Jan 2019 15:15:07 -0800 Subject: [PATCH 049/567] deleted irrelevant file --- samples/test.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 samples/test.txt diff --git a/samples/test.txt b/samples/test.txt deleted file mode 100644 index 06104799f..000000000 --- a/samples/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -project1/project1/Book3 -project1/Book3 \ No newline at end of file From 78858a6efdcbf1891496eb096e63d8aa840e3ba1 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 22 Jan 2019 17:31:53 -0800 Subject: [PATCH 050/567] added enabled materialized views on selected workbooks by a list of workbook names --- samples/materialize_workbooks.py | 33 +++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 13fbb6bc0..e6127bb43 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -21,7 +21,8 @@ def main(): parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', 'project_id', 'project_path'], help='type of content you want to update materialized views settings on') - parser.add_argument('--file-path', '-fp', required=False, help='path to a list of workbooks') + parser.add_argument('--path-list', '-pl', required=False, help='path to a list of workbook paths') + parser.add_argument('--name-list', '-nl', required=False, help='path to a list of workbook names') parser.add_argument('--project-name', '-pn', required=False, help='name of the project') parser.add_argument('--project-path', '-pp', required=False, help="path of the project") @@ -168,16 +169,19 @@ def parse_workbook_path(file_path): def update_workbook(args, enable_materialized_views, password, site_content_url): - if args.file_path is None: - print("Use '--file-path ' to specify the path of a list of workbooks") + if args.path_list is None and args.name_list is None: + print("Use '--path-list ' or '--name-list ' to specify the path of a list of workbooks") print('\n') return False tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - workbook_path_mapping = parse_workbook_path(args.file_path) - all_projects = {project.id: project for project in TSC.Pager(server.projects)} - update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping) + if args.path_list is not None: + workbook_path_mapping = parse_workbook_path(args.path_list) + all_projects = {project.id: project for project in TSC.Pager(server.projects)} + update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping) + elif args.name_list is not None: + update_workbooks_by_names(args.name_list, server, enable_materialized_views) return True @@ -194,7 +198,22 @@ def update_workbooks_by_paths(all_projects, enable_materialized_views, server, w workbook.materialized_views_enabled = enable_materialized_views server.workbooks.update(workbook) print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) - print('\n') + print('\n') + + +def update_workbooks_by_names(name_list, server, enable_materialized_views): + workbook_names = open(name_list, 'r') + for workbook_name in workbook_names: + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + workbook_name.rstrip())) + workbooks = list(TSC.Pager(server.workbooks, req_option)) + for workbook in workbooks: + workbook.materialized_views_enabled = enable_materialized_views + server.workbooks.update(workbook) + print("Updated materialized views settings for workbook: {}".format(workbook.name)) + print('\n') def update_site(args, enable_materialized_views, password, site_content_url): From 699a9828f52c622a095fa04fbc01011e0c04dac0 Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 23 Jan 2019 18:40:00 -0800 Subject: [PATCH 051/567] added materialized views for update workbook/site tests, changed get_by_content_url to correct rest api version --- tableauserverclient/server/endpoint/sites_endpoint.py | 2 +- test/assets/site_update.xml | 2 +- test/assets/workbook_update.xml | 2 +- test/test_site.py | 1 + test/test_workbook.py | 2 ++ 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index d0938ecae..6d67fe69e 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -45,7 +45,7 @@ def get_by_name(self, site_name): return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Gets 1 site by content url - @api(version="3.3") + @api(version="2.0") def get_by_content_url(self, content_url): if content_url is None: error = "Content URL undefined." diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index ade302fef..716314d29 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 2470347a8..23a176fef 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_site.py b/test/test_site.py index 21f44a69d..f95e200f6 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -105,6 +105,7 @@ def test_update(self): self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) + self.assertEqual(True, single_site.materialized_views_enabled) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') diff --git a/test/test_workbook.py b/test/test_workbook.py index d4e2275f4..4bc408e7e 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -119,6 +119,7 @@ def test_update(self): single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_workbook.name = 'renamedWorkbook' + single_workbook.materialized_views_enabled = True single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) @@ -126,6 +127,7 @@ def test_update(self): self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) self.assertEqual('renamedWorkbook', single_workbook.name) + self.assertEqual(True, single_workbook.materialized_views_enabled) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 17921d45e2a4dcaefe3f3908dc63e810e24b90c1 Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 23 Jan 2019 18:54:11 -0800 Subject: [PATCH 052/567] added comments to help users understand how to use the sample python script to update/check on materialized views settings --- samples/materialize_workbooks.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index e6127bb43..f3ffab591 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -44,27 +44,36 @@ def main(): print("Use '--type --mode ' to update materialized views settings.") return + # enable/disable materialized views for site if args.type == 'site': if not update_site(args, enable_materialized_views, password, site_content_url): return + # enable/disable materialized views for workbook + # works only when the site the workbooks belong to are enabled too elif args.type == 'workbook': if not update_workbook(args, enable_materialized_views, password, site_content_url): return + # enable/disable materialized views for project by project name + # will show possible projects when project name is not unique elif args.type == 'project_name': if not update_project_by_name(args, enable_materialized_views, password, site_content_url): return + # enable/disable materialized views for proejct by project path, for example: project1/project2 elif args.type == 'project_path': if not update_project_by_path(args, enable_materialized_views, password, site_content_url): return + # show enabled sites and workbooks if args.status: show_materialized_views_status(args, password, site_content_url) def find_project_path(project, all_projects, path): + # project stores the id of it's parent + # this method is to run recursively to find the path from root project to given project path = project.name if len(path) == 0 else project.name + '/' + path if project.parent_id is None: @@ -96,6 +105,8 @@ def show_materialized_views_status(args, password, site_content_url): # For server admin, this will prints all the materialized views enabled sites # For other users, this only prints the status of the site they belong to print("Materialized views is enabled on sites:") + # only server admins can get all the sites in the server + # other users can only get the site they are in for site in TSC.Pager(server.sites): if site.materialized_views_enabled: enabled_sites.add(site) @@ -160,6 +171,7 @@ def update_project(project, server, enable_materialized_views): def parse_workbook_path(file_path): + # parse the list of project path of workbooks workbook_paths = open(file_path, 'r') workbook_path_mapping = defaultdict(list) for workbook_path in workbook_paths: From 68a5159cfeecfce67ed44d6ba6bad76f0a0129d2 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 30 Jan 2019 12:57:50 -0800 Subject: [PATCH 053/567] adding support for 'getWorkbookPdf' endpoint of REST API (#376) --- tableauserverclient/models/workbook_item.py | 11 +++++++++++ .../server/endpoint/workbooks_endpoint.py | 19 +++++++++++++++++++ test/test_workbook.py | 19 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 17cad293a..203e04c99 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -14,6 +14,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self._created_at = None self._id = None self._initial_tags = set() + self._pdf = None self._preview_image = None self._project_name = None self._size = None @@ -45,6 +46,13 @@ def created_at(self): def id(self): return self._id + @property + def pdf(self): + if self._pdf is None: + error = "Workbook item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + @property def preview_image(self): if self._preview_image is None: @@ -105,6 +113,9 @@ def _set_connections(self, connections): def _set_views(self, views): self._views = views + def _set_pdf(self, pdf): + self._pdf = pdf + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e4d7da466..772ed79b9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -178,6 +178,25 @@ def _get_workbook_connections(self, workbook_item, req_options=None): connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections + # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled + @api(version="3.4") + def populate_pdf(self, workbook_item, req_options=None): + if not workbook_item.id: + error = "Workbook item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_wb_pdf(workbook_item, req_options) + + workbook_item._set_pdf(pdf_fetcher) + logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + + def _get_wb_pdf(self, workbook_item, req_options): + url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item): diff --git a/test/test_workbook.py b/test/test_workbook.py index 4bc408e7e..380131578 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -14,6 +14,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') +POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') @@ -271,6 +272,24 @@ def test_populate_connections_missing_id(self): self.server.workbooks.populate_connections, single_workbook) + def test_populate_pdf(self): + self.server.version = "3.4" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=response) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + + self.server.workbooks.populate_pdf(single_workbook, req_option) + self.assertEqual(response, single_workbook.pdf) + def test_populate_preview_image(self): with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: response = f.read() From ade073cb94bc7d206201250e7d992d1fb313185b Mon Sep 17 00:00:00 2001 From: daniel1608 Date: Wed, 30 Jan 2019 21:59:57 +0100 Subject: [PATCH 054/567] refresh sample uses workbook instead of workbook id (#342) --- samples/refresh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/refresh.py b/samples/refresh.py index 73aa7fb2f..58e3110f3 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -44,7 +44,7 @@ def main(): resource = server.workbooks.get_by_id(args.resource_id) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - results = server.workbooks.refresh(resource) + results = server.workbooks.refresh(args.resource_id) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) From 0a099f47d94626bf47d9ad2f776593ec26e8edb6 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 30 Jan 2019 13:27:24 -0800 Subject: [PATCH 055/567] adding SupportUser role (#392) --- tableauserverclient/models/user_item.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 47d12b662..48e942ece 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -22,7 +22,9 @@ class Roles: ReadOnly = 'ReadOnly' SiteAdministratorCreator = 'SiteAdministratorCreator' SiteAdministratorExplorer = 'SiteAdministratorExplorer' - UnlicensedWithPublish = 'UnlicensedWithPublish' + + # Online only + SupportUser = 'SupportUser' class Auth: SAML = 'SAML' From 440b6cf82ba76bf8acf2a02cd1f8e9be04830101 Mon Sep 17 00:00:00 2001 From: bzhang Date: Mon, 4 Feb 2019 18:14:20 -0800 Subject: [PATCH 056/567] added 'run-materialization-now' flag for workbooks: --- samples/materialize_workbooks.py | 84 +++++++++++++------ samples/name.txt | 1 + .../models/property_decorators.py | 16 ++++ tableauserverclient/models/site_item.py | 33 ++++---- tableauserverclient/models/workbook_item.py | 51 ++++++++--- tableauserverclient/server/request_factory.py | 14 +++- test/test_site.py | 4 +- test/test_workbook.py | 2 +- 8 files changed, 144 insertions(+), 61 deletions(-) create mode 100644 samples/name.txt diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index f3ffab591..3ae9dae4b 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -10,7 +10,8 @@ def main(): parser.add_argument('--server', '-s', required=True, help='Tableau server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--password', '-p', required=False, help='password to sign into server') - parser.add_argument('--mode', '-m', required=False, choices=['enable', 'disable'], + # TODO: for workbook, only disable and enable + parser.add_argument('--mode', '-m', required=False, choices=['disable', 'enable', 'enable_all', 'enable_selective'], help='enable/disable materialized views for sites/workbooks') parser.add_argument('--status', '-st', required=False, action='store_true', help='show materialized views enabled sites/workbooks') @@ -25,6 +26,8 @@ def main(): parser.add_argument('--name-list', '-nl', required=False, help='path to a list of workbook names') parser.add_argument('--project-name', '-pn', required=False, help='name of the project') parser.add_argument('--project-path', '-pp', required=False, help="path of the project") + parser.add_argument('--materialize-now', '-mn', required=False, action='store_true', + help='create materialized views for workbooks immediately') args = parser.parse_args() @@ -38,32 +41,32 @@ def main(): # site content url is the TSC term for site id site_content_url = args.site_id if args.site_id is not None else "" - enable_materialized_views = args.mode == "enable" - if (args.type is None) != (args.mode is None): - print("Use '--type --mode ' to update materialized views settings.") + if not assert_options_valid(args): return + materialized_views_config = create_materialized_views_config(args) + # enable/disable materialized views for site if args.type == 'site': - if not update_site(args, enable_materialized_views, password, site_content_url): + if not update_site(args, password, site_content_url): return # enable/disable materialized views for workbook # works only when the site the workbooks belong to are enabled too elif args.type == 'workbook': - if not update_workbook(args, enable_materialized_views, password, site_content_url): + if not update_workbook(args, materialized_views_config, password, site_content_url): return # enable/disable materialized views for project by project name # will show possible projects when project name is not unique elif args.type == 'project_name': - if not update_project_by_name(args, enable_materialized_views, password, site_content_url): + if not update_project_by_name(args, materialized_views_config, password, site_content_url): return - # enable/disable materialized views for proejct by project path, for example: project1/project2 + # enable/disable materialized views for project by project path, for example: project1/project2 elif args.type == 'project_path': - if not update_project_by_path(args, enable_materialized_views, password, site_content_url): + if not update_project_by_path(args, materialized_views_config, password, site_content_url): return # show enabled sites and workbooks @@ -108,7 +111,7 @@ def show_materialized_views_status(args, password, site_content_url): # only server admins can get all the sites in the server # other users can only get the site they are in for site in TSC.Pager(server.sites): - if site.materialized_views_enabled: + if site.materialized_views_mode != "disable": enabled_sites.add(site) print("Site name: {}".format(site.name)) print('\n') @@ -119,11 +122,11 @@ def show_materialized_views_status(args, password, site_content_url): site_auth = TSC.TableauAuth(args.username, password, site.content_url) with server.auth.sign_in(site_auth): for workbook in TSC.Pager(server.workbooks): - if workbook.materialized_views_enabled: + if workbook.materialized_views_config['materialized_views_enabled']: print("Workbook: {} from site: {}".format(workbook.name, site.name)) -def update_project_by_path(args, enable_materialized_views, password, site_content_url): +def update_project_by_path(args, materialized_views_mode, password, site_content_url): if args.project_path is None: print("Use --project_path to specify the path of the project") return False @@ -134,11 +137,11 @@ def update_project_by_path(args, enable_materialized_views, password, site_conte projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] possible_paths = get_project_paths(server, projects) - update_project(possible_paths[args.project_path], server, enable_materialized_views) + update_project(possible_paths[args.project_path], server, materialized_views_mode) return True -def update_project_by_name(args, enable_materialized_views, password, site_content_url): +def update_project_by_name(args, materialized_views_config, password, site_content_url): if args.project_name is None: print("Use --project-name to specify the name of the project") return False @@ -156,14 +159,14 @@ def update_project_by_name(args, enable_materialized_views, password, site_conte print('\n') return False else: - update_project(projects[0], server, enable_materialized_views) + update_project(projects[0], server, materialized_views_config) return True -def update_project(project, server, enable_materialized_views): +def update_project(project, server, materialized_views_config): for workbook in TSC.Pager(server.workbooks): if workbook.project_id == project.id: - workbook.materialized_views_enabled = enable_materialized_views + workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) print("Updated materialized views settings for project: {}".format(project.name)) @@ -180,7 +183,7 @@ def parse_workbook_path(file_path): return workbook_path_mapping -def update_workbook(args, enable_materialized_views, password, site_content_url): +def update_workbook(args, materialized_views_config, password, site_content_url): if args.path_list is None and args.name_list is None: print("Use '--path-list ' or '--name-list ' to specify the path of a list of workbooks") print('\n') @@ -191,13 +194,13 @@ def update_workbook(args, enable_materialized_views, password, site_content_url) if args.path_list is not None: workbook_path_mapping = parse_workbook_path(args.path_list) all_projects = {project.id: project for project in TSC.Pager(server.projects)} - update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping) + update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping) elif args.name_list is not None: - update_workbooks_by_names(args.name_list, server, enable_materialized_views) + update_workbooks_by_names(args.name_list, server, materialized_views_config) return True -def update_workbooks_by_paths(all_projects, enable_materialized_views, server, workbook_path_mapping): +def update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping): for workbook_name, workbook_paths in workbook_path_mapping.items(): req_option = TSC.RequestOptions() req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, @@ -207,13 +210,13 @@ def update_workbooks_by_paths(all_projects, enable_materialized_views, server, w for workbook in workbooks: path = find_project_path(all_projects[workbook.project_id], all_projects, "") if path in workbook_paths: - workbook.materialized_views_enabled = enable_materialized_views + workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) print('\n') -def update_workbooks_by_names(name_list, server, enable_materialized_views): +def update_workbooks_by_names(name_list, server, materialized_views_config): workbook_names = open(name_list, 'r') for workbook_name in workbook_names: req_option = TSC.RequestOptions() @@ -222,18 +225,20 @@ def update_workbooks_by_names(name_list, server, enable_materialized_views): workbook_name.rstrip())) workbooks = list(TSC.Pager(server.workbooks, req_option)) for workbook in workbooks: - workbook.materialized_views_enabled = enable_materialized_views + workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) print("Updated materialized views settings for workbook: {}".format(workbook.name)) print('\n') -def update_site(args, enable_materialized_views, password, site_content_url): +def update_site(args, password, site_content_url): + if not assert_site_options_valid(args): + return False tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): site_to_update = server.sites.get_by_content_url(site_content_url) - site_to_update.materialized_views_enabled = enable_materialized_views + site_to_update.materialized_views_mode = args.mode server.sites.update(site_to_update) print("Updated materialized views settings for site: {}".format(site_to_update.name)) @@ -241,5 +246,32 @@ def update_site(args, enable_materialized_views, password, site_content_url): return True +def create_materialized_views_config(args): + # TODO: if clean up now and enable all for site is both True, then abort + materialized_views_config = dict() + materialized_views_config['materialized_views_enabled'] = args.mode == "enable" + materialized_views_config['run_materialization_now'] = True if args.materialize_now else False + return materialized_views_config + + +def assert_site_options_valid(args): + if args.materialize_now: + print('"--materialize-now" only applies to workbook/project type') + return False + if args.mode == 'enable': + print('For site type please choose from "disable", "enable_all", or "enable_selective"') + return False + return True + + +def assert_options_valid(args): + if args.type != "site" and args.mode in ("enable_all", "enable_selective"): + print('"enable_all" and "enable_selective" do not apply to workbook/project type') + return False + if (args.type is None) != (args.mode is None): + print("Use '--type --mode ' to update materialized views settings.") + return False + return True + if __name__ == "__main__": main() diff --git a/samples/name.txt b/samples/name.txt new file mode 100644 index 000000000..d827232a2 --- /dev/null +++ b/samples/name.txt @@ -0,0 +1 @@ +project1/Book1 \ No newline at end of file diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index f8a8662a8..7918034a9 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -136,3 +136,19 @@ def wrapper(self, value): dt = parse_datetime(value) return func(self, dt) return wrapper + + +def property_is_materialized_views_config(func): + @wraps(func) + def wrapper(self, value): + if not isinstance(value, dict): + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, + func.__name__)) + if len(value) != 2 or not all(attr in value.keys() for attr in ('materialized_views_enabled', + 'run_materialization_now')): + error = "{} should have 2 keys ".format(func.__name__) + error += "'materialized_views_enabled' and 'run_materialization_now'" + error += "instead you have {}".format(value.keys()) + raise ValueError(error) + return func(self, value) + return wrapper diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 4be047430..f7e75ea54 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, materialized_views_enabled=False): + revision_limit=None, materialized_views_mode=False): self._admin_mode = None self._id = None self._num_users = None @@ -33,7 +33,7 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.revision_history_enabled = revision_history_enabled self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode - self.materialized_views_enabled = materialized_views_enabled + self.materialized_views_mode = materialized_views_mode @property def admin_mode(self): @@ -125,13 +125,12 @@ def subscribe_others_enabled(self, value): self._subscribe_others_enabled = value @property - def materialized_views_enabled(self): - return self._materialized_views_enabled + def materialized_views_mode(self): + return self._materialized_views_mode - @materialized_views_enabled.setter - @property_is_boolean - def materialized_views_enabled(self, value): - self._materialized_views_enabled = value + @materialized_views_mode.setter + def materialized_views_mode(self, value): + self._materialized_views_mode = value def is_default(self): return self.name.lower() == 'default' @@ -143,16 +142,16 @@ def _parse_common_tags(self, site_xml, ns): (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_enabled) = self._parse_element(site_xml, ns) + materialized_views_mode) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_enabled) + revision_limit, num_users, storage, materialized_views_mode) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_enabled): + user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode): if id is not None: self._id = id if name: @@ -181,8 +180,8 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._num_users = num_users if storage: self._storage = storage - if materialized_views_enabled: - self._materialized_views_enabled = materialized_views_enabled + if materialized_views_mode: + self._materialized_views_mode = materialized_views_mode @classmethod def from_response(cls, resp, ns): @@ -192,13 +191,13 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_enabled) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, materialized_views_mode) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_enabled) + materialized_views_mode) all_site_items.append(site_item) return all_site_items @@ -233,11 +232,11 @@ def _parse_element(site_xml, ns): num_users = usage_elem.get('numUsers', None) storage = usage_elem.get('storage', None) - materialized_views_enabled = string_to_bool(site_xml.get('materializedViewsEnabled', '')) + materialized_views_mode = site_xml.get('materializedViewsMode', '') return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, materialized_views_enabled + revision_limit, num_users, storage, materialized_views_mode # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 17cad293a..4a797ce62 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +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 ..datetime_helpers import parse_datetime @@ -24,7 +24,8 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() - self.materialized_views_enabled = None + self.materialized_views_config = {'materialized_views_enabled': False, + 'run_materialization_now': False} @property def connections(self): @@ -99,6 +100,15 @@ def views(self): # We had views included in a WorkbookItem response return self._views + @property + def materialized_views_config(self): + return self._materialized_views_config + + @materialized_views_config.setter + @property_is_materialized_views_config + def materialized_views_config(self, value): + self._materialized_views_config = value + def _set_connections(self, connections): self._connections = connections @@ -114,17 +124,17 @@ def _parse_common_tags(self, workbook_xml, ns): if workbook_xml is not None: (_, _, _, _, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, - materialized_views_enabled) = self._parse_element(workbook_xml, ns) + materialized_views_config) = self._parse_element(workbook_xml, ns) self._set_values(None, None, None, None, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, - materialized_views_enabled) + materialized_views_config) return self def _set_values(self, id, name, content_url, created_at, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, - materialized_views_enabled): + materialized_views_config): if id is not None: self._id = id if name: @@ -150,8 +160,8 @@ def _set_values(self, id, name, content_url, created_at, updated_at, self._initial_tags = copy.copy(tags) if views: self._views = views - if materialized_views_enabled is not None: - self.materialized_views_enabled = materialized_views_enabled + if materialized_views_config is not None: + self.materialized_views_config = materialized_views_config @classmethod def from_response(cls, resp, ns): @@ -161,12 +171,12 @@ def from_response(cls, resp, ns): for workbook_xml in all_workbook_xml: (id, name, content_url, created_at, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, - materialized_views_enabled) = cls._parse_element(workbook_xml, ns) + materialized_views_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) workbook_item._set_values(id, name, content_url, created_at, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, - materialized_views_enabled) + materialized_views_config) all_workbook_items.append(workbook_item) return all_workbook_items @@ -207,10 +217,29 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) - materialized_views_enabled = string_to_bool(workbook_xml.get('materializedViewsEnabled', '')) + materialized_views_config = dict() + materialized_views_elem = workbook_xml.find('.//t:materializedViewsConfig', namespaces=ns) + if materialized_views_elem is not None: + materialized_views_config = parse_materialized_views_config(materialized_views_elem) return id, name, content_url, created_at, updated_at, size, show_tabs,\ - project_id, project_name, owner_id, tags, views, materialized_views_enabled + project_id, project_name, owner_id, tags, views, materialized_views_config + + +def parse_materialized_views_config(materialized_views_elem): + materialized_views_config = dict() + + materialized_views_enabled = materialized_views_elem.get('materializedViewsEnabled', None) + if materialized_views_enabled is not None: + materialized_views_enabled = string_to_bool(materialized_views_enabled) + + run_materialization_now = materialized_views_elem.get('runMaterializationNow', None) + if run_materialization_now is not None: + run_materialization_now = string_to_bool(run_materialization_now) + + materialized_views_config['materialized_views_enabled'] = materialized_views_enabled + materialized_views_config['run_materialization_now'] = run_materialization_now + return materialized_views_config # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d9d40951b..3809e390c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -290,8 +290,8 @@ def update_req(self, site_item): site_element.attrib['revisionLimit'] = str(site_item.revision_limit) if site_item.subscribe_others_enabled: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() - if site_item.materialized_views_enabled is not None: - site_element.attrib['materializedViewsEnabled'] = str(site_item.materialized_views_enabled).lower() + if site_item.materialized_views_mode is not None: + site_element.attrib['materializedViewsMode'] = str(site_item.materialized_views_mode).lower() return ET.tostring(xml_request) def create_req(self, site_item): @@ -382,8 +382,14 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_enabled is not None: - workbook_element.attrib['materializedViewsEnabled'] = str(workbook_item.materialized_views_enabled).lower() + if workbook_item.materialized_views_config is not None: + materialized_views_config = workbook_item.materialized_views_config + materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsConfig') + materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config + ["materialized_views_enabled"]).lower() + materialized_views_element.attrib['runMaterializationNow'] = str(materialized_views_config + ["run_materialization_now"]).lower() + return ET.tostring(xml_request) def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): diff --git a/test/test_site.py b/test/test_site.py index f95e200f6..3f49e6958 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -92,7 +92,7 @@ def test_update(self): admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, disable_subscriptions=True, revision_history_enabled=False, - materialized_views_enabled=False) + materialization_mode=False) single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) @@ -105,7 +105,7 @@ def test_update(self): self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) - self.assertEqual(True, single_site.materialized_views_enabled) + self.assertEqual(True, single_site.materialized_views_mode) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') diff --git a/test/test_workbook.py b/test/test_workbook.py index 4bc408e7e..bbb2ffde1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -127,7 +127,7 @@ def test_update(self): self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) self.assertEqual('renamedWorkbook', single_workbook.name) - self.assertEqual(True, single_workbook.materialized_views_enabled) + self.assertEqual(True, single_workbook.materialized_views_mode) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 6a7a0657e2879cf661cdd71251163ad9497c51cb Mon Sep 17 00:00:00 2001 From: bzhang Date: Mon, 4 Feb 2019 18:38:25 -0800 Subject: [PATCH 057/567] do not allow update on workbooks/projects when site is disabled for materialized views --- samples/materialize_workbooks.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 3ae9dae4b..133a9ee05 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -10,7 +10,6 @@ def main(): parser.add_argument('--server', '-s', required=True, help='Tableau server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--password', '-p', required=False, help='password to sign into server') - # TODO: for workbook, only disable and enable parser.add_argument('--mode', '-m', required=False, choices=['disable', 'enable', 'enable_all', 'enable_selective'], help='enable/disable materialized views for sites/workbooks') parser.add_argument('--status', '-st', required=False, action='store_true', @@ -134,6 +133,8 @@ def update_project_by_path(args, materialized_views_mode, password, site_content server = TSC.Server(args.server, use_server_version=True) project_name = args.project_path.split('/')[-1] with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] possible_paths = get_project_paths(server, projects) @@ -148,6 +149,8 @@ def update_project_by_name(args, materialized_views_config, password, site_conte tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False # get all projects with given name projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] @@ -191,6 +194,8 @@ def update_workbook(args, materialized_views_config, password, site_content_url) tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): + if not assert_site_enabled_for_materialized_views(server, site_content_url): + return False if args.path_list is not None: workbook_path_mapping = parse_workbook_path(args.path_list) all_projects = {project.id: project for project in TSC.Pager(server.projects)} @@ -273,5 +278,14 @@ def assert_options_valid(args): return False return True + +def assert_site_enabled_for_materialized_views(server, site_content_url): + parent_site = server.sites.get_by_content_url(site_content_url) + if parent_site.materialized_views_mode == "disable": + print('Cannot update workbook/project because site is disabled for materialized views') + return False + return True + + if __name__ == "__main__": main() From eaf86e4fe67ee7403d34c5e84b7ba1d683660f6e Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 5 Feb 2019 12:59:13 -0800 Subject: [PATCH 058/567] check if project name is valid --- samples/materialize_workbooks.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 133a9ee05..6333b783a 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -18,8 +18,7 @@ def main(): help='set to Default site by default') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', - 'project_id', 'project_path'], + parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', 'project_path'], help='type of content you want to update materialized views settings on') parser.add_argument('--path-list', '-pl', required=False, help='path to a list of workbook paths') parser.add_argument('--name-list', '-nl', required=False, help='path to a list of workbook names') @@ -136,7 +135,9 @@ def update_project_by_path(args, materialized_views_mode, password, site_content if not assert_site_enabled_for_materialized_views(server, site_content_url): return False projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] - + if not assert_project_valid(args, args.project_path, projects): + return False + possible_paths = get_project_paths(server, projects) update_project(possible_paths[args.project_path], server, materialized_views_mode) return True @@ -153,6 +154,8 @@ def update_project_by_name(args, materialized_views_config, password, site_conte return False # get all projects with given name projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] + if not assert_project_valid(args, args.project_name, projects): + return False if len(projects) > 1: possible_paths = get_project_paths(server, projects) @@ -287,5 +290,12 @@ def assert_site_enabled_for_materialized_views(server, site_content_url): return True +def assert_project_valid(args, project_name, projects): + if len(projects) == 0: + print("Cannot find project: {}".format(project_name)) + return False + return True + + if __name__ == "__main__": main() From dcf8c87e9a2322e93fd649b4ff2170c8c3e39021 Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 5 Feb 2019 13:54:54 -0800 Subject: [PATCH 059/567] fixed tests --- samples/materialize_workbooks.py | 8 ++++---- test/assets/workbook_update.xml | 3 ++- test/test_workbook.py | 6 ++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 6333b783a..456ca1f5f 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -135,9 +135,9 @@ def update_project_by_path(args, materialized_views_mode, password, site_content if not assert_site_enabled_for_materialized_views(server, site_content_url): return False projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] - if not assert_project_valid(args, args.project_path, projects): + if not assert_project_valid(args.project_path, projects): return False - + possible_paths = get_project_paths(server, projects) update_project(possible_paths[args.project_path], server, materialized_views_mode) return True @@ -154,7 +154,7 @@ def update_project_by_name(args, materialized_views_config, password, site_conte return False # get all projects with given name projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] - if not assert_project_valid(args, args.project_name, projects): + if not assert_project_valid(args.project_name, projects): return False if len(projects) > 1: @@ -290,7 +290,7 @@ def assert_site_enabled_for_materialized_views(server, site_content_url): return True -def assert_project_valid(args, project_name, projects): +def assert_project_valid(project_name, projects): if len(projects) == 0: print("Cannot find project: {}".format(project_name)) return False diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 23a176fef..45efacdeb 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -1,8 +1,9 @@ - + + \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index bbb2ffde1..a9ebaf52f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -119,7 +119,8 @@ def test_update(self): single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_workbook.name = 'renamedWorkbook' - single_workbook.materialized_views_enabled = True + single_workbook.materialized_views_config = {'materialized_views_enabled': True, + 'run_materialization_now': False} single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) @@ -127,7 +128,8 @@ def test_update(self): self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) self.assertEqual('renamedWorkbook', single_workbook.name) - self.assertEqual(True, single_workbook.materialized_views_mode) + self.assertEqual(True, single_workbook.materialized_views_config['materialized_views_enabled']) + self.assertEqual(False, single_workbook.materialized_views_config['run_materialization_now']) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 57cd89022a9d9ed3c1465c657337c702186efa6e Mon Sep 17 00:00:00 2001 From: bzhang Date: Tue, 5 Feb 2019 14:41:25 -0800 Subject: [PATCH 060/567] fixed tests --- test/test_site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_site.py b/test/test_site.py index 3f49e6958..9603e73c2 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -92,7 +92,7 @@ def test_update(self): admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, disable_subscriptions=True, revision_history_enabled=False, - materialization_mode=False) + materialized_views_mode='disable') single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) @@ -105,7 +105,7 @@ def test_update(self): self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) - self.assertEqual(True, single_site.materialized_views_mode) + self.assertEqual('disable', single_site.materialized_views_mode) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') From b9f04bb4341e3325ca668a6178b7b2089201110e Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 6 Feb 2019 12:52:33 -0800 Subject: [PATCH 061/567] removed temporary file and changed initial materialized views setting for site/workbook_item to None --- samples/materialize_workbooks.py | 1 - samples/name.txt | 1 - tableauserverclient/models/site_item.py | 2 +- tableauserverclient/models/workbook_item.py | 6 +++--- tableauserverclient/server/request_factory.py | 2 +- test/assets/workbook_update.xml | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 samples/name.txt diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 456ca1f5f..485ca2fa0 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -255,7 +255,6 @@ def update_site(args, password, site_content_url): def create_materialized_views_config(args): - # TODO: if clean up now and enable all for site is both True, then abort materialized_views_config = dict() materialized_views_config['materialized_views_enabled'] = args.mode == "enable" materialized_views_config['run_materialization_now'] = True if args.materialize_now else False diff --git a/samples/name.txt b/samples/name.txt deleted file mode 100644 index d827232a2..000000000 --- a/samples/name.txt +++ /dev/null @@ -1 +0,0 @@ -project1/Book1 \ No newline at end of file diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index f7e75ea54..21031ff80 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, materialized_views_mode=False): + revision_limit=None, materialized_views_mode=None): self._admin_mode = None self._id = None self._num_users = None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 4a797ce62..3b6f2d860 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -24,8 +24,8 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() - self.materialized_views_config = {'materialized_views_enabled': False, - 'run_materialization_now': False} + self.materialized_views_config = {'materialized_views_enabled': None, + 'run_materialization_now': None} @property def connections(self): @@ -218,7 +218,7 @@ def _parse_element(workbook_xml, ns): views = ViewItem.from_xml_element(views_elem, ns) materialized_views_config = dict() - materialized_views_elem = workbook_xml.find('.//t:materializedViewsConfig', namespaces=ns) + materialized_views_elem = workbook_xml.find('.//t:materializedViewsEnablementConfig', namespaces=ns) if materialized_views_elem is not None: materialized_views_config = parse_materialized_views_config(materialized_views_elem) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3809e390c..142e90775 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -384,7 +384,7 @@ def update_req(self, workbook_item): owner_element.attrib['id'] = workbook_item.owner_id if workbook_item.materialized_views_config is not None: materialized_views_config = workbook_item.materialized_views_config - materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsConfig') + materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config ["materialized_views_enabled"]).lower() materialized_views_element.attrib['runMaterializationNow'] = str(materialized_views_config diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 45efacdeb..7a72759d8 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -4,6 +4,6 @@ - + \ No newline at end of file From 5e186b9ff3a103bbbed26d352c72e84f420b524f Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 6 Feb 2019 14:49:11 -0800 Subject: [PATCH 062/567] fixed tests --- samples/name.txt | 2 ++ tableauserverclient/models/workbook_item.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 samples/name.txt diff --git a/samples/name.txt b/samples/name.txt new file mode 100644 index 000000000..9db947b3d --- /dev/null +++ b/samples/name.txt @@ -0,0 +1,2 @@ +92 08 23 +Book2 \ No newline at end of file diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 3b6f2d860..f1eaa82d5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -217,7 +217,7 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) - materialized_views_config = dict() + materialized_views_config = {'materialized_views_enabled': None, 'run_materialization_now': None} materialized_views_elem = workbook_xml.find('.//t:materializedViewsEnablementConfig', namespaces=ns) if materialized_views_elem is not None: materialized_views_config = parse_materialized_views_config(materialized_views_elem) From 418af7d4d38a1ee9a0519ee05099716af9267eff Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 6 Feb 2019 14:59:00 -0800 Subject: [PATCH 063/567] fixed some indentation issues --- tableauserverclient/models/property_decorators.py | 2 +- tableauserverclient/server/request_factory.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 7918034a9..a4ef0ef3f 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -143,7 +143,7 @@ def property_is_materialized_views_config(func): def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, - func.__name__)) + func.__name__)) if len(value) != 2 or not all(attr in value.keys() for attr in ('materialized_views_enabled', 'run_materialization_now')): error = "{} should have 2 keys ".format(func.__name__) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 142e90775..23b5e3f42 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -386,7 +386,7 @@ def update_req(self, workbook_item): materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config - ["materialized_views_enabled"]).lower() + ["materialized_views_enabled"]).lower() materialized_views_element.attrib['runMaterializationNow'] = str(materialized_views_config ["run_materialization_now"]).lower() From ad83f801235cf255c5ee2754fed08271553f47e2 Mon Sep 17 00:00:00 2001 From: bzhang Date: Wed, 6 Feb 2019 18:16:08 -0800 Subject: [PATCH 064/567] when update one project, update all the sub-project of this project too --- samples/materialize_workbooks.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index 485ca2fa0..a349a6c99 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -170,8 +170,10 @@ def update_project_by_name(args, materialized_views_config, password, site_conte def update_project(project, server, materialized_views_config): + all_projects = list(TSC.Pager(server.projects)) + project_ids = find_project_ids_to_update(all_projects, project, server) for workbook in TSC.Pager(server.workbooks): - if workbook.project_id == project.id: + if workbook.project_id in project_ids: workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) @@ -179,6 +181,12 @@ def update_project(project, server, materialized_views_config): print('\n') +def find_project_ids_to_update(all_projects, project, server): + projects_to_update = [] + find_projects_to_update(project, server, all_projects, projects_to_update) + return set([project_to_update.id for project_to_update in projects_to_update]) + + def parse_workbook_path(file_path): # parse the list of project path of workbooks workbook_paths = open(file_path, 'r') @@ -296,5 +304,16 @@ def assert_project_valid(project_name, projects): return True +def find_projects_to_update(project, server, all_projects, projects_to_update): + # Use recursion to find all the sub-projects and enable/disable the workbooks in them + projects_to_update.append(project) + children_projects = [child for child in all_projects if child.parent_id == project.id] + if len(children_projects) == 0: + return + + for child in children_projects: + find_projects_to_update(child, server, all_projects, projects_to_update) + + if __name__ == "__main__": main() From 4cae50e1f0fd96cea81610b0c344778572d156ad Mon Sep 17 00:00:00 2001 From: bzhang Date: Thu, 7 Feb 2019 21:46:45 -0800 Subject: [PATCH 065/567] fixed a bug that materializedNow flag is not passed to server correctly --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 23b5e3f42..d3c4a776d 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -387,7 +387,7 @@ def update_req(self, workbook_item): materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config ["materialized_views_enabled"]).lower() - materialized_views_element.attrib['runMaterializationNow'] = str(materialized_views_config + materialized_views_element.attrib['materializeNow'] = str(materialized_views_config ["run_materialization_now"]).lower() return ET.tostring(xml_request) From 19d01984a0d7e9a33b6c3dd6caddcd4ca2980940 Mon Sep 17 00:00:00 2001 From: bzhang Date: Fri, 8 Feb 2019 10:13:08 -0800 Subject: [PATCH 066/567] fixed identation issue --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d3c4a776d..7f0a3ac3b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -388,7 +388,7 @@ def update_req(self, workbook_item): materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config ["materialized_views_enabled"]).lower() materialized_views_element.attrib['materializeNow'] = str(materialized_views_config - ["run_materialization_now"]).lower() + ["run_materialization_now"]).lower() return ET.tostring(xml_request) From 3efafb262a54ca9bcff87bd187ddf7752f071e1d Mon Sep 17 00:00:00 2001 From: Bruce Zhang Date: Tue, 19 Feb 2019 15:33:10 -0800 Subject: [PATCH 067/567] Fixed the bug that materialize_workbooks.py cannot handle empty lines (#401) * fixed the bug that materialize_workbooks.py cannot handle empty lines in workbook list, and not user get notified when workbook name/path is not valid * check for invalid file names, when can't find workbook name/path, remind user to use new line separated file as workbook list * notify user when file name is invalid --- samples/materialize_workbooks.py | 41 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py index a349a6c99..696dda4b7 100644 --- a/samples/materialize_workbooks.py +++ b/samples/materialize_workbooks.py @@ -1,6 +1,7 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC from collections import defaultdict @@ -124,7 +125,7 @@ def show_materialized_views_status(args, password, site_content_url): print("Workbook: {} from site: {}".format(workbook.name, site.name)) -def update_project_by_path(args, materialized_views_mode, password, site_content_url): +def update_project_by_path(args, materialized_views_config, password, site_content_url): if args.project_path is None: print("Use --project_path to specify the path of the project") return False @@ -139,7 +140,7 @@ def update_project_by_path(args, materialized_views_mode, password, site_content return False possible_paths = get_project_paths(server, projects) - update_project(possible_paths[args.project_path], server, materialized_views_mode) + update_project(possible_paths[args.project_path], server, materialized_views_config) return True @@ -171,7 +172,7 @@ def update_project_by_name(args, materialized_views_config, password, site_conte def update_project(project, server, materialized_views_config): all_projects = list(TSC.Pager(server.projects)) - project_ids = find_project_ids_to_update(all_projects, project, server) + project_ids = find_project_ids_to_update(all_projects, project) for workbook in TSC.Pager(server.workbooks): if workbook.project_id in project_ids: workbook.materialized_views_config = materialized_views_config @@ -181,15 +182,16 @@ def update_project(project, server, materialized_views_config): print('\n') -def find_project_ids_to_update(all_projects, project, server): +def find_project_ids_to_update(all_projects, project): projects_to_update = [] - find_projects_to_update(project, server, all_projects, projects_to_update) + find_projects_to_update(project, all_projects, projects_to_update) return set([project_to_update.id for project_to_update in projects_to_update]) def parse_workbook_path(file_path): # parse the list of project path of workbooks - workbook_paths = open(file_path, 'r') + workbook_paths = sanitize_workbook_list(file_path, "path") + workbook_path_mapping = defaultdict(list) for workbook_path in workbook_paths: workbook_project = workbook_path.rstrip().split('/') @@ -223,23 +225,32 @@ def update_workbooks_by_paths(all_projects, materialized_views_config, server, w TSC.RequestOptions.Operator.Equals, workbook_name)) workbooks = list(TSC.Pager(server.workbooks, req_option)) + all_paths = set(workbook_paths[:]) for workbook in workbooks: path = find_project_path(all_projects[workbook.project_id], all_projects, "") if path in workbook_paths: + all_paths.remove(path) workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) + + for path in all_paths: + print("Cannot find workbook path: {}, each line should only contain one workbook path" + .format(path + '/' + workbook_name)) print('\n') def update_workbooks_by_names(name_list, server, materialized_views_config): - workbook_names = open(name_list, 'r') + workbook_names = sanitize_workbook_list(name_list, "name") for workbook_name in workbook_names: req_option = TSC.RequestOptions() req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, workbook_name.rstrip())) workbooks = list(TSC.Pager(server.workbooks, req_option)) + if len(workbooks) == 0: + print("Cannot find workbook name: {}, each line should only contain one workbook name" + .format(workbook_name)) for workbook in workbooks: workbook.materialized_views_config = materialized_views_config server.workbooks.update(workbook) @@ -304,7 +315,7 @@ def assert_project_valid(project_name, projects): return True -def find_projects_to_update(project, server, all_projects, projects_to_update): +def find_projects_to_update(project, all_projects, projects_to_update): # Use recursion to find all the sub-projects and enable/disable the workbooks in them projects_to_update.append(project) children_projects = [child for child in all_projects if child.parent_id == project.id] @@ -312,7 +323,19 @@ def find_projects_to_update(project, server, all_projects, projects_to_update): return for child in children_projects: - find_projects_to_update(child, server, all_projects, projects_to_update) + find_projects_to_update(child, all_projects, projects_to_update) + + +def sanitize_workbook_list(file_name, file_type): + if not os.path.isfile(file_name): + print("Invalid file name '{}'".format(file_name)) + return [] + file_list = open(file_name, "r") + + if file_type == "name": + return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] + if file_type == "path": + return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] if __name__ == "__main__": From cacc4fb719ec01145cde4c5a7b35ac44e1ef2ae4 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 20 Feb 2019 09:03:33 -0800 Subject: [PATCH 068/567] Added support for flows (#403) * adding support for flows * added test for querying schedules --- tableauserverclient/models/schedule_item.py | 1 + test/assets/schedule_get.xml | 1 + test/test_schedule.py | 47 +++++++++++++-------- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 3e97ccc15..11c403764 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -9,6 +9,7 @@ class ScheduleItem(object): class Type: Extract = "Extract" + Flow = "Flow" Subscription = "Subscription" class ExecutionOrder: diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml index 3d8578ede..66e4d6e51 100644 --- a/test/assets/schedule_get.xml +++ b/test/assets/schedule_get.xml @@ -4,5 +4,6 @@ + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index a9ae9bb67..b5aadcbca 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -36,24 +36,37 @@ def test_get(self): m.get(self.baseurl, text=response_xml) all_schedules, pagination_item = self.server.schedules.get() + extract = all_schedules[0] + subscription = all_schedules[1] + flow = all_schedules[2] + self.assertEqual(2, pagination_item.total_available) - self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", all_schedules[0].id) - self.assertEqual("Weekday early mornings", all_schedules[0].name) - self.assertEqual("Active", all_schedules[0].state) - self.assertEqual(50, all_schedules[0].priority) - self.assertEqual("2016-07-06T20:19:00Z", format_datetime(all_schedules[0].created_at)) - self.assertEqual("2016-09-13T11:00:32Z", format_datetime(all_schedules[0].updated_at)) - self.assertEqual("Extract", all_schedules[0].schedule_type) - self.assertEqual("2016-09-14T11:00:00Z", format_datetime(all_schedules[0].next_run_at)) - - self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", all_schedules[1].id) - self.assertEqual("Saturday night", all_schedules[1].name) - self.assertEqual("Active", all_schedules[1].state) - self.assertEqual(80, all_schedules[1].priority) - self.assertEqual("2016-07-07T20:19:00Z", format_datetime(all_schedules[1].created_at)) - self.assertEqual("2016-09-12T16:39:38Z", format_datetime(all_schedules[1].updated_at)) - self.assertEqual("Subscription", all_schedules[1].schedule_type) - self.assertEqual("2016-09-18T06:00:00Z", format_datetime(all_schedules[1].next_run_at)) + self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) + self.assertEqual("Weekday early mornings", extract.name) + self.assertEqual("Active", extract.state) + self.assertEqual(50, extract.priority) + self.assertEqual("2016-07-06T20:19:00Z", format_datetime(extract.created_at)) + self.assertEqual("2016-09-13T11:00:32Z", format_datetime(extract.updated_at)) + self.assertEqual("Extract", extract.schedule_type) + self.assertEqual("2016-09-14T11:00:00Z", format_datetime(extract.next_run_at)) + + self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", subscription.id) + self.assertEqual("Saturday night", subscription.name) + self.assertEqual("Active", subscription.state) + self.assertEqual(80, subscription.priority) + self.assertEqual("2016-07-07T20:19:00Z", format_datetime(subscription.created_at)) + self.assertEqual("2016-09-12T16:39:38Z", format_datetime(subscription.updated_at)) + self.assertEqual("Subscription", subscription.schedule_type) + self.assertEqual("2016-09-18T06:00:00Z", format_datetime(subscription.next_run_at)) + + self.assertEqual("f456e8f2-aeb2-4a8e-b823-00b6f08640f0", flow.id) + self.assertEqual("First of the month 1:00AM", flow.name) + self.assertEqual("Active", flow.state) + self.assertEqual(50, flow.priority) + self.assertEqual("2019-02-19T18:52:19Z", format_datetime(flow.created_at)) + self.assertEqual("2019-02-19T18:55:51Z", format_datetime(flow.updated_at)) + self.assertEqual("Flow", flow.schedule_type) + self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) def test_get_empty(self): with open(GET_EMPTY_XML, "rb") as f: From fe2ff8f2175d43f72eaf018fd01d13369e9975f9 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 2 Apr 2019 14:39:34 -0700 Subject: [PATCH 069/567] Adding tests for 500 error handling (#364) * Adding tests for 500 error handling * fixing python 2.7 test failures * fixing style failure --- test/test_datasource.py | 12 ++++++++++++ test/test_requests.py | 10 ++++++++++ test/test_workbook.py | 20 ++++++++++++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index 1b21c0194..8c1095175 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -4,6 +4,7 @@ import xml.etree.ElementTree as ET import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset @@ -313,3 +314,14 @@ def test_credentials_and_multi_connect_raises_exception(self): response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds, connections=[connection1]) + + def test_synchronous_publish_timeout_error(self): + with requests_mock.mock() as m: + m.register_uri('POST', self.baseurl, status_code=504) + + 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) diff --git a/test/test_requests.py b/test/test_requests.py index 686a4bbb4..80216ec85 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -5,6 +5,8 @@ import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import InternalServerError + class RequestTests(unittest.TestCase): def setUp(self): @@ -45,3 +47,11 @@ def test_make_post_request(self): self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') self.assertEqual(resp.request.headers['content-type'], 'multipart/mixed') self.assertEqual(resp.request.body, b'1337') + + # Test that 500 server errors are handled properly + def test_internal_server_error(self): + self.server.version = "3.2" + 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) diff --git a/test/test_workbook.py b/test/test_workbook.py index 41bbc440c..ae814c0b2 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -5,7 +5,9 @@ import xml.etree.ElementTree as ET from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory +from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -319,11 +321,11 @@ def test_publish(self): show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, - sample_workbok, + sample_workbook, publish_mode) self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) @@ -350,11 +352,11 @@ def test_publish_async(self): show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - sample_workbok = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') publish_mode = self.server.PublishMode.CreateNew new_job = self.server.workbooks.publish(new_workbook, - sample_workbok, + sample_workbook, publish_mode, as_job=True) @@ -421,3 +423,13 @@ def test_credentials_and_multi_connect_raises_exception(self): response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds, connections=[connection1]) + + def test_synchronous_publish_timeout_error(self): + with requests_mock.mock() as m: + m.register_uri('POST', self.baseurl, status_code=504) + + 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) From 6907a1537ae53b42a3d2cd9e8147966070540a16 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 2 Apr 2019 14:39:53 -0700 Subject: [PATCH 070/567] Add parsing for embed_password field and allow updating value to false (#416) --- tableauserverclient/models/connection_item.py | 6 ++++++ tableauserverclient/server/request_factory.py | 4 ++-- .../datasource_populate_connections.xml | 5 ++--- test/test_datasource.py | 20 ++++++++++++------- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 894cabe62..829564839 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -44,6 +44,7 @@ def from_response(cls, resp, ns): connection_item = cls() connection_item._id = connection_xml.get('id', None) connection_item._connection_type = connection_xml.get('type', None) + connection_item.embed_password = string_to_bool(connection_xml.get('embedPassword', '')) connection_item.server_address = connection_xml.get('serverAddress', None) connection_item.server_port = connection_xml.get('serverPort', None) connection_item.username = connection_xml.get('userName', None) @@ -82,3 +83,8 @@ def from_xml_element(cls, parsed_response, ns): connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) return all_connection_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7f0a3ac3b..0e528d002 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -422,8 +422,8 @@ def update_req(self, xml_request, connection_item): connection_element.attrib['userName'] = connection_item.username if connection_item.password: connection_element.attrib['password'] = connection_item.password - if connection_item.embed_password: - connection_element.attrib['embedPassword'] = str(connection_item.embed_password) + if connection_item.embed_password is not None: + connection_element.attrib['embedPassword'] = str(connection_item.embed_password).lower() class TaskRequest(object): diff --git a/test/assets/datasource_populate_connections.xml b/test/assets/datasource_populate_connections.xml index 442a78323..eaaa24934 100644 --- a/test/assets/datasource_populate_connections.xml +++ b/test/assets/datasource_populate_connections.xml @@ -1,8 +1,7 @@ - - - + + \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index 8c1095175..0563d2af7 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -141,15 +141,21 @@ def test_populate_connections(self): single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.datasources.populate_connections(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) - connections = single_datasource.connections - self.assertTrue(connections) - ds1, ds2, ds3 = connections - self.assertEqual(ds1.id, 'be786ae0-d2bf-4a4b-9b34-e2de8d2d4488') - self.assertEqual(ds2.id, '970e24bc-e200-4841-a3e9-66e7d122d77e') - self.assertEqual(ds3.id, '7d85b889-283b-42df-b23e-3c811e402f1f') + + self.assertTrue(connections) + ds1, ds2 = connections + self.assertEqual('be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', ds1.id) + self.assertEqual('textscan', ds1.connection_type) + self.assertEqual('forty-two.net', ds1.server_address) + self.assertEqual('duo', ds1.username) + self.assertEqual(True, ds1.embed_password) + self.assertEqual('970e24bc-e200-4841-a3e9-66e7d122d77e', ds2.id) + self.assertEqual('sqlserver', ds2.connection_type) + self.assertEqual('database.com', ds2.server_address) + self.assertEqual('heero', ds2.username) + self.assertEqual(False, ds2.embed_password) def test_update_connection(self): populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) From 8fd8d781a5436c1795fbecfae482560fd4b55828 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Mon, 8 Apr 2019 10:24:49 -0700 Subject: [PATCH 071/567] Changelog and contributors for 0.8 (#421) --- CHANGELOG.md | 12 ++++++++++++ CONTRIBUTORS.md | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aab3ed7..421d577fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.8 (8 Apr 2019) + +* Added Max Age to download view image request (#360) +* Added Materialized Views (#378, #394, #396) +* Added PDF export of Workbook (#376) +* Added Support User Role (#392) +* Added Flows (#403) +* Updated Pager to handle un-paged results (#322) +* Fixed checked upload (#309, #319, #326, #329) +* Fixed embed_password field on publish (#416) + + ## 0.7 (2 Jul 2018) * Added cancel job (#299) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 25ac5718b..bffde46c7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,6 +16,9 @@ The following people have contributed to this project to make it possible, and w * [Jim Morris](https://github.com/jimbodriven) * [BingoDinkus](https://github.com/BingoDinkus) * [Sergey Sotnichenko](https://github.com/sotnich) +* [Bruce Zhang](https://github.com/baixin137) +* [Bumsoo Kim](https://github.com/bskim45) +* [daniel1608](https://github.com/daniel1608) ## Core Team @@ -27,3 +30,5 @@ The following people have contributed to this project to make it possible, and w * [Jared Dominguez](https://github.com/jdomingu) * [Jackson Huang](https://github.com/jz-huang) * [Brendan Lee](https://github.com/lbrendanl) +* [Ang Gao](https://github.com/gaoang2148) +* [Priya R](https://github.com/preguraman) From 5de51ff00a3417337bc87dab19126fe26c1625bf Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 24 Apr 2019 14:56:09 -0700 Subject: [PATCH 072/567] docs - clarify tags must be a list https://github.com/tableau/server-client-python/issues/52 --- docs/docs/api-ref.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md index 68f86321f..c75ddeffb 100644 --- a/docs/docs/api-ref.md +++ b/docs/docs/api-ref.md @@ -244,7 +244,7 @@ Name | Description `name` | The name of the data source. If not specified, the name of the published data source file is used. `project_id` | The identifier of the project associated with the data source. When you must provide this identifier when create an instance of a `DatasourceItem` `project_name` | The name of the project associated with the data source. -`tags` | The tags that have been added to the data source. +`tags` | The tags that have been added to the data source. This is a list of strings, e.g ["tag"]. `updated_at` | The date and time when the data source was last updated. @@ -2546,7 +2546,7 @@ Name | Description `project_name` | The name of the project. `size` | The size of the workbook (in megabytes). `show_tabs` | (Boolean) Determines whether the workbook shows tabs for the view. -`tags` | The tags that have been added to the workbook. +`tags` | The tags that have been added to the workbook. This is a list of strings, e.g ["tag"]. `updated_at` | The date and time when the workbook was last updated. `views` | The list of views (`ViewItem`) for the workbook. You must first call the [workbooks.populate_views](#workbooks.populate_views) method to access this data. See the [ViewItem class](#viewitem-class). From 5fef974e0b58cd9d5fc1ec0e05eb103f90af29fa Mon Sep 17 00:00:00 2001 From: martin dertz Date: Sat, 27 Apr 2019 09:33:57 -0400 Subject: [PATCH 073/567] comments --- samples/refresh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/refresh.py b/samples/refresh.py index 58e3110f3..437a5ffa1 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to use trigger a refresh on a datasource or workbook +# This script demonstrates how to trigger a refresh on a datasource or workbook # # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### From 07030af2c130be53fdd75cba38224eeafc1660fb Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 12:32:40 -0700 Subject: [PATCH 074/567] Initial support for Tableau Metadata API --- .../server/endpoint/__init__.py | 1 + .../server/endpoint/exceptions.py | 9 +++ .../server/endpoint/metadata_endpoint.py | 32 +++++++++ tableauserverclient/server/server.py | 5 +- test/assets/metadata_query_error.json | 29 ++++++++ test/assets/metadata_query_success.json | 22 ++++++ test/test_metadata.py | 69 +++++++++++++++++++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tableauserverclient/server/endpoint/metadata_endpoint.py create mode 100644 test/assets/metadata_query_error.json create mode 100644 test/assets/metadata_query_success.json create mode 100644 test/test_metadata.py diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index c75fe8519..24881b2e4 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -4,6 +4,7 @@ from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .jobs_endpoint import Jobs +from .metadata_endpoint import Metadata from .projects_endpoint import Projects from .schedules_endpoint import Schedules from .server_info_endpoint import ServerInfo diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 080eca9c8..0648d8814 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -44,3 +44,12 @@ class EndpointUnavailableError(Exception): class ItemTypeNotAllowed(Exception): pass + + +class GraphQLError(Exception): + def __init__(self, error_payload): + self.error = error_payload + + def __str__(self): + from pprint import pformat + return pformat(self.error) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py new file mode 100644 index 000000000..cc4da06e5 --- /dev/null +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -0,0 +1,32 @@ +from .endpoint import Endpoint, api +from .exceptions import GraphQLError +import logging +import json + +logger = logging.getLogger('tableau.endpoint.metadata') + + +class Metadata(Endpoint): + @property + def baseurl(self): + return "{0}/api/exp/metadata/graphql".format(self.parent_srv._server_address) + + @api("3.2") + def query(self, query, abort_on_error=False): + logger.info('Querying Metadata API') + url = self.baseurl + + try: + graphql_query = json.dumps({'query': query}) + except Exception: + # Place holder for now + raise Exception('Must provide a string') + + # Setting content type because post_reuqest defaults to text/xml + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = json.loads(server_response.content) + + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + + return results diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 95ee564ee..536b3982a 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,8 +3,8 @@ from .exceptions import NotSignedInError from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs -from .endpoint.exceptions import EndpointUnavailableError + Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata +from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -50,6 +50,7 @@ def __init__(self, server_address, use_server_version=False): self.server_info = ServerInfo(self) self.tasks = Tasks(self) self.subscriptions = Subscriptions(self) + self.metadata = Metadata(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/metadata_query_error.json b/test/assets/metadata_query_error.json new file mode 100644 index 000000000..1c575ee23 --- /dev/null +++ b/test/assets/metadata_query_error.json @@ -0,0 +1,29 @@ +{ + "data": { + "publishedDatasources": [ + { + "id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", + "name": "Batters (TestV1)" + }, + { + "id": "020ae1cd-c356-f1ad-a846-b0094850d22a", + "name": "SharePoint_List_sharepoint2010.test.tsi.lan" + }, + { + "id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", + "name": "Batters_mongodb" + }, + { + "id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", + "name": "Sample - Superstore" + } + ] + }, + "errors": [ + { + "message": "Reached time limit of PT5S for query execution.", + "path": null, + "extensions": null + } + ] +} \ No newline at end of file diff --git a/test/assets/metadata_query_success.json b/test/assets/metadata_query_success.json new file mode 100644 index 000000000..056f29fb6 --- /dev/null +++ b/test/assets/metadata_query_success.json @@ -0,0 +1,22 @@ +{ + "data": { + "publishedDatasources": [ + { + "id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", + "name": "Batters (TestV1)" + }, + { + "id": "020ae1cd-c356-f1ad-a846-b0094850d22a", + "name": "SharePoint_List_sharepoint2010.test.tsi.lan" + }, + { + "id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", + "name": "Batters_mongodb" + }, + { + "id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", + "name": "Sample - Superstore" + } + ] + } + } \ No newline at end of file diff --git a/test/test_metadata.py b/test/test_metadata.py new file mode 100644 index 000000000..cf19fec05 --- /dev/null +++ b/test/test_metadata.py @@ -0,0 +1,69 @@ +import unittest +import os.path +import json +import requests_mock +import tableauserverclient as TSC + +from tableauserverclient.server.endpoint.exceptions import GraphQLError + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') +METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') + +EXPECTED_DICT = {'publishedDatasources': + [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, + {'id': '020ae1cd-c356-f1ad-a846-b0094850d22a', 'name': 'SharePoint_List_sharepoint2010.test.tsi.lan'}, + {'id': '061493a0-c3b2-6f39-d08c-bc3f842b44af', 'name': 'Batters_mongodb'}, + {'id': '089fe515-ad2f-89bc-94bd-69f55f69a9c2', 'name': 'Sample - Superstore'}]} + +EXPECTED_DICT_ERROR = [ + { + "message": "Reached time limit of PT5S for query execution.", + "path": None, + "extensions": None + } +] + + +class MetadataTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.baseurl = self.server.metadata.baseurl + self.server.version = "3.2" + + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + def test_metadata_query(self): + with open(METADATA_QUERY_SUCCESS, 'rb') as f: + response_json = json.load(f) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + actual = self.server.metadata.query('fake query') + + datasources = actual['data'] + + self.assertDictEqual(EXPECTED_DICT, datasources) + + def test_metadata_query_ignore_error(self): + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = json.load(f) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + actual = self.server.metadata.query('fake query') + datasources = actual['data'] + + self.assertNotEqual(actual.get('errors', None), None) + self.assertListEqual(EXPECTED_DICT_ERROR, actual['errors']) + self.assertDictEqual(EXPECTED_DICT, datasources) + + def test_metadata_query_abort_on_error(self): + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = json.load(f) + with requests_mock.mock() as m: + m.post(self.baseurl, json=response_json) + + with self.assertRaises(GraphQLError) as e: + self.server.metadata.query('fake query', abort_on_error=True) + self.assertListEqual(e.error, EXPECTED_DICT_ERROR) From 395598fc04eca84c1867f77b2fb412ac184503b9 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 12:39:27 -0700 Subject: [PATCH 075/567] fix encoding error --- test/test_metadata.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_metadata.py b/test/test_metadata.py index cf19fec05..99beeda8a 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -36,7 +36,7 @@ def setUp(self): self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' def test_metadata_query(self): - with open(METADATA_QUERY_SUCCESS, 'rb') as f: + with open(METADATA_QUERY_SUCCESS, 'r') as f: response_json = json.load(f) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) @@ -47,7 +47,7 @@ def test_metadata_query(self): self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_ignore_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, 'r') as f: response_json = json.load(f) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) @@ -59,7 +59,7 @@ def test_metadata_query_ignore_error(self): self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_abort_on_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, 'r') as f: response_json = json.load(f) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) From 4c0bbeb357db0c5eaf52ef99aabf7d3a5d82d446 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 13:37:29 -0700 Subject: [PATCH 076/567] stop doing weird things --- test/test_metadata.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/test_metadata.py b/test/test_metadata.py index 99beeda8a..4cf3c7074 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -36,10 +36,10 @@ def setUp(self): self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' def test_metadata_query(self): - with open(METADATA_QUERY_SUCCESS, 'r') as f: - response_json = json.load(f) + with open(METADATA_QUERY_SUCCESS, 'rb') as f: + response_json = f.read() with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) + m.post(self.baseurl, content=response_json) actual = self.server.metadata.query('fake query') datasources = actual['data'] @@ -47,10 +47,10 @@ def test_metadata_query(self): self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_ignore_error(self): - with open(METADATA_QUERY_ERROR, 'r') as f: - response_json = json.load(f) + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = f.read() with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) + m.post(self.baseurl, content=response_json) actual = self.server.metadata.query('fake query') datasources = actual['data'] @@ -59,10 +59,10 @@ def test_metadata_query_ignore_error(self): self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_abort_on_error(self): - with open(METADATA_QUERY_ERROR, 'r') as f: - response_json = json.load(f) + with open(METADATA_QUERY_ERROR, 'rb') as f: + response_json = f.read() with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) + m.post(self.baseurl, content=response_json) with self.assertRaises(GraphQLError) as e: self.server.metadata.query('fake query', abort_on_error=True) From 9377d919ecb616db8637d86dffb50d43934c8d90 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 13:44:52 -0700 Subject: [PATCH 077/567] Just use built in json parsing --- .../server/endpoint/metadata_endpoint.py | 3 ++- test/test_metadata.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index cc4da06e5..706a68f61 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -24,7 +24,8 @@ def query(self, query, abort_on_error=False): # Setting content type because post_reuqest defaults to text/xml server_response = self.post_request(url, graphql_query, content_type='text/json') - results = json.loads(server_response.content) + breakpoint() + results = server_response.json() if abort_on_error and results.get('errors', None): raise GraphQLError(results['errors']) diff --git a/test/test_metadata.py b/test/test_metadata.py index 4cf3c7074..cf19fec05 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -37,9 +37,9 @@ def setUp(self): def test_metadata_query(self): with open(METADATA_QUERY_SUCCESS, 'rb') as f: - response_json = f.read() + response_json = json.load(f) with requests_mock.mock() as m: - m.post(self.baseurl, content=response_json) + m.post(self.baseurl, json=response_json) actual = self.server.metadata.query('fake query') datasources = actual['data'] @@ -48,9 +48,9 @@ def test_metadata_query(self): def test_metadata_query_ignore_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: - response_json = f.read() + response_json = json.load(f) with requests_mock.mock() as m: - m.post(self.baseurl, content=response_json) + m.post(self.baseurl, json=response_json) actual = self.server.metadata.query('fake query') datasources = actual['data'] @@ -60,9 +60,9 @@ def test_metadata_query_ignore_error(self): def test_metadata_query_abort_on_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: - response_json = f.read() + response_json = json.load(f) with requests_mock.mock() as m: - m.post(self.baseurl, content=response_json) + m.post(self.baseurl, json=response_json) with self.assertRaises(GraphQLError) as e: self.server.metadata.query('fake query', abort_on_error=True) From c0670f22dff5664e2c266e6f17b084c7b62bb0fa Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 13:47:33 -0700 Subject: [PATCH 078/567] last one --- tableauserverclient/server/endpoint/metadata_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 706a68f61..82f91844c 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -24,7 +24,6 @@ def query(self, query, abort_on_error=False): # Setting content type because post_reuqest defaults to text/xml server_response = self.post_request(url, graphql_query, content_type='text/json') - breakpoint() results = server_response.json() if abort_on_error and results.get('errors', None): From e971d780051d2c9084ea989279fd41dd32b255c9 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 13:58:20 -0700 Subject: [PATCH 079/567] Can only test on CI --- test/test_metadata.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_metadata.py b/test/test_metadata.py index cf19fec05..e2a44734c 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -37,7 +37,7 @@ def setUp(self): def test_metadata_query(self): with open(METADATA_QUERY_SUCCESS, 'rb') as f: - response_json = json.load(f) + response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) actual = self.server.metadata.query('fake query') @@ -48,7 +48,7 @@ def test_metadata_query(self): def test_metadata_query_ignore_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: - response_json = json.load(f) + response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) actual = self.server.metadata.query('fake query') @@ -60,7 +60,7 @@ def test_metadata_query_ignore_error(self): def test_metadata_query_abort_on_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: - response_json = json.load(f) + response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) From d8bdeae8fd4cf0ace41013ea8099feb39ca81b7e Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 7 May 2019 16:39:56 -0700 Subject: [PATCH 080/567] Update .travis.yml (#432) 3.3 and 3.4 are EOL, as well as not significantly different than the other 3.X's. Dropping them and pypy (which is also not significantly different at this point, and is slow to run the pipeline), and adding 3.7 with the new required dist. --- .travis.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 01ad30886..cc261b20c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,10 @@ +dist: xenial language: python python: - "2.7" - - "3.3" - - "3.4" - "3.5" - "3.6" - - "pypy" + - "3.7" # command to install dependencies install: - "pip install -e ." @@ -14,5 +13,4 @@ install: script: # Tests - python setup.py test - # pep8 - disabled for now until we can scrub the files to make sure we pass before turning it on - pycodestyle tableauserverclient test samples From e4ec849b19f02f016f90e641756d9bd81d23d026 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Fri, 10 May 2019 14:03:49 -0700 Subject: [PATCH 081/567] Add support for Cataloging and Prep Conductor to TSC. (#434) * Add support for Cataloging and Prep Conductor to TSC. These are toggable only when your server is licensed for the Data Management Add On, and are ignored otherwise --- tableauserverclient/models/site_item.py | 42 +++++++++++++++---- tableauserverclient/server/request_factory.py | 8 ++++ test/assets/site_update.xml | 2 +- test/test_site.py | 2 + 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 21031ff80..238332597 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, materialized_views_mode=None): + revision_limit=None, materialized_views_mode=None, flows_enabled=None, cataloging_enabled=None): self._admin_mode = None self._id = None self._num_users = None @@ -34,6 +34,8 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode self.materialized_views_mode = materialized_views_mode + self.cataloging_enabled = cataloging_enabled + self.flows_enabled = flows_enabled @property def admin_mode(self): @@ -132,6 +134,22 @@ def materialized_views_mode(self): def materialized_views_mode(self, value): self._materialized_views_mode = value + @property + def cataloging_enabled(self): + return self._cataloging_enabled + + @cataloging_enabled.setter + def cataloging_enabled(self, value): + self._cataloging_enabled = value + + @property + def flows_enabled(self): + return self._flows_enabled + + @flows_enabled.setter + def flows_enabled(self, value): + self._flows_enabled = value + def is_default(self): return self.name.lower() == 'default' @@ -142,16 +160,18 @@ def _parse_common_tags(self, site_xml, ns): (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode) = self._parse_element(site_xml, ns) + materialized_views_mode, cataloging_enabled, flows_enabled) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode) + revision_limit, num_users, storage, materialized_views_mode, cataloging_enabled, + flows_enabled) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode): + user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode, + flows_enabled, cataloging_enabled): if id is not None: self._id = id if name: @@ -182,6 +202,10 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._storage = storage if materialized_views_mode: self._materialized_views_mode = materialized_views_mode + if flows_enabled is not None: + self.flows_enabled = flows_enabled + if cataloging_enabled is not None: + self.cataloging_enabled = cataloging_enabled @classmethod def from_response(cls, resp, ns): @@ -191,13 +215,14 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, materialized_views_mode, flows_enabled, + cataloging_enabled) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode) + materialized_views_mode, flows_enabled, cataloging_enabled) all_site_items.append(site_item) return all_site_items @@ -234,9 +259,12 @@ def _parse_element(site_xml, ns): materialized_views_mode = site_xml.get('materializedViewsMode', '') + flows_enabled = string_to_bool(site_xml.get('flowsEnabled', '')) + cataloging_enabled = string_to_bool(site_xml.get('catalogingEnabled', '')) + return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, materialized_views_mode + revision_limit, num_users, storage, materialized_views_mode, flows_enabled, cataloging_enabled # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 0e528d002..31c204110 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -292,6 +292,10 @@ def update_req(self, site_item): site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() if site_item.materialized_views_mode is not None: site_element.attrib['materializedViewsMode'] = str(site_item.materialized_views_mode).lower() + if site_item.flows_enabled is not None: + site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + if site_item.cataloging_enabled is not None: + site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() return ET.tostring(xml_request) def create_req(self, site_item): @@ -307,6 +311,10 @@ def create_req(self, site_item): site_element.attrib['storageQuota'] = str(site_item.storage_quota) if site_item.disable_subscriptions: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() + if site_item.flows_enabled is not None: + site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + if site_item.cataloging_enabled is not None: + site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() return ET.tostring(xml_request) diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index 716314d29..30e434373 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/test_site.py b/test/test_site.py index 9603e73c2..8283a7bdd 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -106,6 +106,8 @@ def test_update(self): self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) self.assertEqual('disable', single_site.materialized_views_mode) + self.assertEqual(True, single_site.flows_enabled) + self.assertEqual(True, single_site.cataloging_enabled) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') From ea437a203dbb9af6a2e73a913226fc60c3291731 Mon Sep 17 00:00:00 2001 From: Joshua Jacob Date: Wed, 29 May 2019 13:22:36 -0700 Subject: [PATCH 082/567] Change server_info endpoint error message to be verbose (#439) First contribution from @jacobj10, fix a bug introduced from a requests upgrade, and some error handling improvements for a swallowed exception --- tableauserverclient/server/endpoint/server_info_endpoint.py | 4 +++- test/test_sort.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 97901d7ae..0a6b9ec89 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,5 @@ from .endpoint import Endpoint, api -from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError +from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError, EndpointUnavailableError from ...models import ServerInfoItem import logging @@ -19,6 +19,8 @@ def get(self): except ServerResponseError as e: if e.code == "404003": raise ServerInfoEndpointNotFoundError + if e.code == "404001": + raise EndpointUnavailableError server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) return server_info diff --git a/test/test_sort.py b/test/test_sort.py index 88c0da728..17a69e900 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -58,7 +58,7 @@ def test_filter_in(self): auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:[stocks,market]') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') def test_sort_asc(self): with requests_mock.mock() as m: From fa818d5b31c0cd92ed14ea8604e333e82071252e Mon Sep 17 00:00:00 2001 From: Joshua Jacob Date: Wed, 19 Jun 2019 12:32:58 -0700 Subject: [PATCH 083/567] Fix serialization error in WB request generation (#449) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 31c204110..fdc799af1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -363,7 +363,7 @@ def _generate_xml(self, workbook_item, connection_credentials=None, connections= if workbook_item.show_tabs: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() project_element = ET.SubElement(workbook_element, 'project') - project_element.attrib['id'] = workbook_item.project_id + project_element.attrib['id'] = str(workbook_item.project_id) if connection_credentials is not None and connections is not None: raise RuntimeError('You cannot set both `connections` and `connection_credentials`') From d87600b479ce8490b312f5a478106824e6a7545e Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 25 Jun 2019 10:41:47 -0700 Subject: [PATCH 084/567] Update Pager to take and pass arguments to underlying callable (#451) This enables us to use `get` methods that take optional parameters, like `usage` on get Views. --- tableauserverclient/server/pager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 92c0f0423..75ac8be4e 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,3 +1,5 @@ +from functools import partial + from . import RequestOptions from . import Sort @@ -11,13 +13,15 @@ class Pager(object): Will loop over anything that returns (List[ModelItem], PaginationItem). """ - def __init__(self, endpoint, request_opts=None): + def __init__(self, endpoint, request_opts=None, **kwargs): if hasattr(endpoint, 'get'): # The simpliest case is to take an Endpoint and call its get - self._endpoint = endpoint.get + endpoint = partial(endpoint.get, **kwargs) + self._endpoint = endpoint elif callable(endpoint): # but if they pass a callable then use that instead (used internally) + endpoint = partial(endpoint, **kwargs) self._endpoint = endpoint else: # Didn't get something we can page over From bdddf97652adb73c8b8f6847b53068cac00abf9d Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 17 Jul 2019 09:13:13 -0700 Subject: [PATCH 085/567] Fix update_workbook endpoint to address #454 (#461) --- tableauserverclient/server/request_factory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fdc799af1..72bf90d80 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -382,7 +382,7 @@ def update_req(self, workbook_item): workbook_element = ET.SubElement(xml_request, 'workbook') if workbook_item.name: workbook_element.attrib['name'] = workbook_item.name - if workbook_item.show_tabs: + if workbook_item.show_tabs is not None: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: project_element = ET.SubElement(workbook_element, 'project') @@ -390,7 +390,8 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config is not None: + if workbook_item.materialized_views_config['materialized_views_enabled']\ + and workbook_item.materialized_views_config['run_materialization_now']: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config From 2f7e361a39e98d52c3ccbfbdaf909c59009eb8f1 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 17 Jul 2019 09:13:13 -0700 Subject: [PATCH 086/567] Fix update_workbook endpoint to address #454 (#461) (cherry picked from commit bdddf97652adb73c8b8f6847b53068cac00abf9d) --- tableauserverclient/server/request_factory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 0e528d002..0a8f5e1ba 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -374,7 +374,7 @@ def update_req(self, workbook_item): workbook_element = ET.SubElement(xml_request, 'workbook') if workbook_item.name: workbook_element.attrib['name'] = workbook_item.name - if workbook_item.show_tabs: + if workbook_item.show_tabs is not None: workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: project_element = ET.SubElement(workbook_element, 'project') @@ -382,7 +382,8 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config is not None: + if workbook_item.materialized_views_config['materialized_views_enabled']\ + and workbook_item.materialized_views_config['run_materialization_now']: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config From 56801d8f72ec2e9a0c670f2d21528e089e1fe0af Mon Sep 17 00:00:00 2001 From: shinchris Date: Wed, 17 Jul 2019 10:08:44 -0700 Subject: [PATCH 087/567] Updates changelog for release 0.8.1 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421d577fd..357a89b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.1 (17 July 2019) + +* Fixed update_workbook endpoint (#454) + ## 0.8 (8 Apr 2019) * Added Max Age to download view image request (#360) From 340d82ff8ee949b4a8c710b15c422828dec3530d Mon Sep 17 00:00:00 2001 From: shinchris Date: Wed, 17 Jul 2019 10:16:26 -0700 Subject: [PATCH 088/567] Fixes test failure --- test/test_sort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_sort.py b/test/test_sort.py index 88c0da728..17a69e900 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -58,7 +58,7 @@ def test_filter_in(self): auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:[stocks,market]') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') def test_sort_asc(self): with requests_mock.mock() as m: From b9ea51e1174936b0617a6e376cbd67dc99a8e38d Mon Sep 17 00:00:00 2001 From: shinchris Date: Wed, 17 Jul 2019 10:20:43 -0700 Subject: [PATCH 089/567] Removes 3.3 and 3.4 from travis --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 01ad30886..f57c19434 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: - "2.7" - - "3.3" - - "3.4" - "3.5" - "3.6" - "pypy" From 11d47ef137120c7cc88825d958315ab53d36e78e Mon Sep 17 00:00:00 2001 From: Francisco Pagliaricci Date: Fri, 26 Jul 2019 13:57:28 -0700 Subject: [PATCH 090/567] Personal Access Token Authentication (#465) * Adding model for PersonalAccessToken authentication information * Making the signin request accept generic credentials * Adding login sample * adding sign in tests for tokens * Fixing lint issues * moving auth credentials to a property * Making PAT use a specific sign in method, to avoid misuing the feature attempting to use it against older versions of the server --- samples/login.py | 52 +++++++++++++++++++ tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + .../models/personal_access_token_auth.py | 11 ++++ tableauserverclient/models/tableau_auth.py | 4 ++ .../server/endpoint/auth_endpoint.py | 7 ++- tableauserverclient/server/request_factory.py | 7 ++- test/test_auth.py | 21 ++++++++ 8 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 samples/login.py create mode 100644 tableauserverclient/models/personal_access_token_auth.py diff --git a/samples/login.py b/samples/login.py new file mode 100644 index 000000000..aaa21ab25 --- /dev/null +++ b/samples/login.py @@ -0,0 +1,52 @@ +#### +# This script demonstrates how to log in to Tableau Server Client. +# +# To run the script, you must have installed Python 2.7.9 or later. +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Logs in to the server.') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + parser.add_argument('--server', '-s', required=True, help='server address') + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--username', '-u', help='username to sign into the server') + group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') + + args = parser.parse_args() + + # Set logging level based on user input, or error by default. + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # Make sure we use an updated version of the rest apis. + server = TSC.Server(args.server, use_server_version=True) + + if args.username: + # Trying to authenticate using username and password. + password = getpass.getpass("Password: ") + tableau_auth = TSC.TableauAuth(args.username, password) + with server.auth.sign_in(tableau_auth): + print('Logged in successfully') + + else: + # Trying to authenticate using personal access tokens. + personal_access_token = getpass.getpass("Personal Access Token: ") + tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, + personal_access_token=personal_access_token) + with server.auth.sign_in_with_personal_access_token(tableau_auth): + print('Logged in successfully') + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 85972d48b..3494e5f1f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,7 +1,7 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ - SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ + SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ SubscriptionItem, Target from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 63a861cbb..872909adb 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -11,6 +11,7 @@ from .server_info_item import ServerInfoItem from .site_item import SiteItem from .tableau_auth import TableauAuth +from .personal_access_token_auth import PersonalAccessTokenAuth from .target import Target from .task_item import TaskItem from .user_item import UserItem diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py new file mode 100644 index 000000000..0bb9b2c02 --- /dev/null +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -0,0 +1,11 @@ +class PersonalAccessTokenAuth(object): + def __init__(self, token_name, personal_access_token, site_id=''): + self.token_name = token_name + self.personal_access_token = personal_access_token + self.site_id = site_id + # Personal Access Tokens doesn't support impersonation. + self.user_id_to_impersonate = None + + @property + def credentials(self): + return {'clientId': self.token_name, 'personalAccessToken': self.personal_access_token} diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 3b60741d6..cf04c1a97 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -25,3 +25,7 @@ def site(self, value): warnings.warn('TableauAuth.site is deprecated, use TableauAuth.site_id instead.', DeprecationWarning) self.site_id = value + + @property + def credentials(self): + return {'name': self.username, 'password': self.password} diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 84938ba63..10f4cb4db 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -35,9 +35,14 @@ def sign_in(self, auth_req): user_id = parsed_response.find('.//t:user', namespaces=self.parent_srv.namespace).get('id', None) auth_token = parsed_response.find('t:credentials', namespaces=self.parent_srv.namespace).get('token', None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info('Signed into {0} as {1}'.format(self.parent_srv.server_address, auth_req.username)) + logger.info('Signed into {0} as user with id {1}'.format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + @api(version="3.6") + def sign_in_with_personal_access_token(self, auth_req): + # We use the same request that username/password login uses. + return self.sign_in(auth_req) + @api(version="2.0") def sign_out(self): url = "{0}/{1}".format(self.baseurl, 'signout') diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 72bf90d80..6a3f811b6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -47,11 +47,14 @@ def _add_credentials_element(parent_element, connection_credentials): class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element('tsRequest') + credentials_element = ET.SubElement(xml_request, 'credentials') - credentials_element.attrib['name'] = auth_item.username - credentials_element.attrib['password'] = auth_item.password + for attribute_name, attribute_value in auth_item.credentials.items(): + credentials_element.attrib[attribute_name] = attribute_value + site_element = ET.SubElement(credentials_element, 'site') site_element.attrib['contentUrl'] = auth_item.site_id + if auth_item.user_id_to_impersonate: user_element = ET.SubElement(credentials_element, 'user') user_element.attrib['id'] = auth_item.user_id_to_impersonate diff --git a/test/test_auth.py b/test/test_auth.py index 870064db0..28e241335 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -27,6 +27,19 @@ def test_sign_in(self): self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + def test_sign_in_with_personal_access_tokens(self): + with open(SIGN_IN_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl + '/signin', text=response_xml) + tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', + personal_access_token='Random123Generated', site_id='Samples') + self.server.auth.sign_in(tableau_auth) + + self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) + self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) + self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + def test_sign_in_impersonate(self): with open(SIGN_IN_IMPERSONATE_XML, 'rb') as f: response_xml = f.read().decode('utf-8') @@ -48,6 +61,14 @@ def test_sign_in_error(self): tableau_auth = TSC.TableauAuth('testuser', 'wrongpassword') self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + def test_sign_in_invalid_token(self): + with open(SIGN_IN_ERROR_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl + '/signin', text=response_xml, status_code=401) + tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', personal_access_token='invalid') + self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, 'rb') as f: response_xml = f.read().decode('utf-8') From 122fe1a9991658686dbf9001f16e0b0c5b5f537c Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Sat, 27 Jul 2019 00:09:58 -0700 Subject: [PATCH 091/567] Permissions Support 2.0 (#429) Add support for getting and setting permissions for all content types. This is accomplished with a few patterns, some new, some old. 1 - We now have a `_PermissionsEndpoint` much like tags, that gets composed into a permissions sub-endpoint for each type. Calls to the type endpoint pass through to the sub-endpoint, just like tags. Item models treat permissions like any populated property. 2 - Permissions are returned as a list of rules, where a rule is a grantee-capability pairing. 3 - We have another sub-endpoint for DefaultPermissions, for Projects and (in another PR), databases. 4 - We have a new `as_reference` and and `to_reference` method on `User` and `Group` types, that let's us create an id-only object for use in permissions parsing, and as a short-hand for those objects when you don't need all the other properties. I think this could expand to all item types, but I haven't yet hit a strong need for it so skipped that for this already giant PR. --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/datasource_item.py | 12 +++ tableauserverclient/models/exceptions.py | 4 + tableauserverclient/models/group_item.py | 13 ++- .../models/permissions_item.py | 93 +++++++++++++++++++ tableauserverclient/models/project_item.py | 43 +++++++++ tableauserverclient/models/reference_item.py | 21 +++++ tableauserverclient/models/user_item.py | 13 ++- tableauserverclient/models/workbook_item.py | 12 +++ tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/datasources_endpoint.py | 20 ++++ .../endpoint/default_permissions_endpoint.py | 79 ++++++++++++++++ .../server/endpoint/endpoint.py | 7 +- .../server/endpoint/exceptions.py | 4 + .../server/endpoint/permissions_endpoint.py | 83 +++++++++++++++++ .../server/endpoint/projects_endpoint.py | 62 ++++++++++++- .../server/endpoint/workbooks_endpoint.py | 16 ++++ tableauserverclient/server/request_factory.py | 38 ++++---- .../datasource_populate_permissions.xml | 23 +++++ test/assets/project_populate_permissions.xml | 16 ++++ ..._populate_workbook_default_permissions.xml | 28 ++++++ test/assets/workbook_populate_permissions.xml | 27 ++++++ test/assets/workbook_update_permissions.xml | 21 +++++ test/test_datasource.py | 27 ++++++ test/test_project.py | 61 +++++++++++- test/test_sort.py | 4 +- test/test_workbook.py | 66 +++++++++++++ 28 files changed, 765 insertions(+), 33 deletions(-) create mode 100644 tableauserverclient/models/permissions_item.py create mode 100644 tableauserverclient/models/reference_item.py create mode 100644 tableauserverclient/server/endpoint/default_permissions_endpoint.py create mode 100644 tableauserverclient/server/endpoint/permissions_endpoint.py create mode 100644 test/assets/datasource_populate_permissions.xml create mode 100644 test/assets/project_populate_permissions.xml create mode 100644 test/assets/project_populate_workbook_default_permissions.xml create mode 100644 test/assets/workbook_populate_permissions.xml create mode 100644 test/assets/workbook_update_permissions.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 3494e5f1f..d1b8a4e74 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,7 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem, Target + SubscriptionItem, Target, PermissionsRule, Permission 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 872909adb..f96f78565 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -18,3 +18,4 @@ from .view_item import ViewItem from .workbook_item import WorkbookItem from .subscription_item import SubscriptionItem +from .permissions_item import PermissionsRule, Permission diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b00e6cbea..e76a42aae 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -23,6 +23,8 @@ def __init__(self, project_id, name=None): self.project_id = project_id self.tags = set() + self._permissions = None + @property def connections(self): if self._connections is None: @@ -30,6 +32,13 @@ def connections(self): raise UnpopulatedPropertyError(error) return self._connections() + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + @property def content_url(self): return self._content_url @@ -84,6 +93,9 @@ def updated_at(self): def _set_connections(self, connections): self._connections = connections + def _set_permissions(self, permissions): + self._permissions = permissions + def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): datasource_xml = ET.fromstring(datasource_xml).find('.//t:datasource', namespaces=ns) diff --git a/tableauserverclient/models/exceptions.py b/tableauserverclient/models/exceptions.py index 28d738e73..86c28ac33 100644 --- a/tableauserverclient/models/exceptions.py +++ b/tableauserverclient/models/exceptions.py @@ -1,2 +1,6 @@ class UnpopulatedPropertyError(Exception): pass + + +class UnknownGranteeTypeError(Exception): + pass diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0efdfa6ea..d37769006 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,10 +1,14 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty +from .reference_item import ResourceReference class GroupItem(object): - def __init__(self, name): + + tag_name = 'group' + + def __init__(self, name=None): self._domain_name = None self._id = None self._users = None @@ -35,6 +39,9 @@ def users(self): # Each call to `.users` should create a new pager, this just runs the callable return self._users() + def to_reference(self): + return ResourceReference(id_=self.id, tag_name=self.tag_name) + def _set_users(self, users): self._users = users @@ -53,3 +60,7 @@ def from_response(cls, resp, ns): group_item._domain_name = domain_elem.get('name', None) all_group_items.append(group_item) return all_group_items + + @staticmethod + def as_reference(id_): + return ResourceReference(id_, GroupItem.tag_name) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py new file mode 100644 index 000000000..2c2abdf82 --- /dev/null +++ b/tableauserverclient/models/permissions_item.py @@ -0,0 +1,93 @@ +import xml.etree.ElementTree as ET +import logging + +from .exceptions import UnknownGranteeTypeError +from .user_item import UserItem +from .group_item import GroupItem + +logger = logging.getLogger('tableau.models.permissions_item') + + +class Permission: + + class Mode: + Allow = 'Allow' + Deny = 'Deny' + + class Capability: + AddComment = 'AddComment' + ChangeHierarchy = 'ChangeHierarchy' + ChangePermissions = 'ChangePermissions' + Connect = 'Connect' + Delete = 'Delete' + ExportData = 'ExportData' + ExportImage = 'ExportImage' + ExportXml = 'ExportXml' + Filter = 'Filter' + ProjectLeader = 'ProjectLeader' + Read = 'Read' + ShareView = 'ShareView' + ViewComments = 'ViewComments' + ViewUnderlyingData = 'ViewUnderlyingData' + WebAuthoring = 'WebAuthoring' + Write = 'Write' + + class Resource: + Workbook = 'workbook' + Datasource = 'datasource' + Flow = 'flow' + + +class PermissionsRule(object): + + def __init__(self, grantee, capabilities): + self.grantee = grantee + self.capabilities = capabilities + + @classmethod + def from_response(cls, resp, ns=None): + parsed_response = ET.fromstring(resp) + + rules = [] + permissions_rules_list_xml = parsed_response.findall('.//t:granteeCapabilities', + namespaces=ns) + + for grantee_capability_xml in permissions_rules_list_xml: + capability_dict = {} + + grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) + + for capability_xml in grantee_capability_xml.findall( + './/t:capabilities/t:capability', namespaces=ns): + name = capability_xml.get('name') + mode = capability_xml.get('mode') + + capability_dict[name] = mode + + rule = PermissionsRule(grantee, + capability_dict) + rules.append(rule) + + return rules + + @staticmethod + def _parse_grantee_element(grantee_capability_xml, ns): + """Use Xpath magic and some string splitting to get the right object type from the xml""" + + # Get the first element in the tree with an 'id' attribute + grantee_element = grantee_capability_xml.findall('.//*[@id]', namespaces=ns).pop() + grantee_id = grantee_element.get('id', None) + grantee_type = grantee_element.tag.split('}').pop() + + if grantee_id is None: + logger.error('Cannot find grantee type in response') + raise UnknownGranteeTypeError() + + if grantee_type == 'user': + grantee = UserItem.as_reference(grantee_id) + elif grantee_type == 'group': + grantee = GroupItem.as_reference(grantee_id) + else: + raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + + return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 92e0282ae..15223e695 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,5 +1,9 @@ import xml.etree.ElementTree as ET + +from .permissions_item import Permission + from .property_decorators import property_is_enum, property_not_empty +from .exceptions import UnpopulatedPropertyError class ProjectItem(object): @@ -15,10 +19,43 @@ def __init__(self, name, description=None, content_permissions=None, parent_id=N self.content_permissions = content_permissions self.parent_id = parent_id + self._permissions = None + self._default_workbook_permissions = None + self._default_datasource_permissions = None + self._default_flow_permissions = None + @property def content_permissions(self): return self._content_permissions + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def default_datasource_permissions(self): + if self._default_datasource_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_datasource_permissions() + + @property + def default_workbook_permissions(self): + if self._default_workbook_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_workbook_permissions() + + @property + def default_flow_permissions(self): + if self._default_flow_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_flow_permissions() + @content_permissions.setter @property_is_enum(ContentPermissions) def content_permissions(self, value): @@ -61,6 +98,12 @@ def _set_values(self, project_id, name, description, content_permissions, parent if parent_id: self.parent_id = parent_id + def _set_permissions(self, permissions): + self._permissions = permissions + + def _set_default_permissions(self, permissions, content_type): + setattr(self, "_default_{content}_permissions".format(content=content_type), permissions) + @classmethod def from_response(cls, resp, ns): all_project_items = list() diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py new file mode 100644 index 000000000..2cf0f0119 --- /dev/null +++ b/tableauserverclient/models/reference_item.py @@ -0,0 +1,21 @@ +class ResourceReference(object): + + def __init__(self, id_, tag_name): + self.id = id_ + self.tag_name = tag_name + + @property + def id(self): + return self._id + + @id.setter + def id(self, value): + self._id = value + + @property + def tag_name(self): + return self._tag_name + + @tag_name.setter + def tag_name(self, value): + self._tag_name = value diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 48e942ece..10ca7527d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,9 +2,13 @@ from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty, property_not_nullable from ..datetime_helpers import parse_datetime +from .reference_item import ResourceReference class UserItem(object): + + tag_name = 'user' + class Roles: Interactor = 'Interactor' Publisher = 'Publisher' @@ -30,7 +34,7 @@ class Auth: SAML = 'SAML' ServerDefault = 'ServerDefault' - def __init__(self, name, site_role, auth_setting=None): + def __init__(self, name=None, site_role=None, auth_setting=None): self._auth_setting = None self._domain_name = None self._external_auth_user_id = None @@ -94,6 +98,9 @@ def workbooks(self): raise UnpopulatedPropertyError(error) return self._workbooks() + def to_reference(self): + return ResourceReference(id_=self.id, tag_name=self.tag_name) + def _set_workbooks(self, workbooks): self._workbooks = workbooks @@ -140,6 +147,10 @@ def from_response(cls, resp, ns): all_user_items.append(user_item) return all_user_items + @staticmethod + def as_reference(id_): + return ResourceReference(id_, UserItem.tag_name) + @staticmethod def _parse_element(user_xml, ns): id = user_xml.get('id', None) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 8df036516..d518f23a4 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -3,6 +3,7 @@ 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 copy @@ -27,6 +28,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self.tags = set() self.materialized_views_config = {'materialized_views_enabled': None, 'run_materialization_now': None} + self._permissions = None @property def connections(self): @@ -35,6 +37,13 @@ def connections(self): raise UnpopulatedPropertyError(error) return self._connections() + @property + def permissions(self): + if self._permissions is None: + error = "Workbook item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + @property def content_url(self): return self._content_url @@ -120,6 +129,9 @@ def materialized_views_config(self, value): def _set_connections(self, connections): self._connections = connections + def _set_permissions(self, permissions): + self._permissions = permissions + def _set_views(self, views): self._views = views diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 8c5cb314c..7fa59ef3c 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -4,7 +4,7 @@ from .sort import Sort from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ - UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem + UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem, PermissionsRule, Permission from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 4d7a20b70..c46a7dc74 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,5 +1,8 @@ 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem @@ -24,6 +27,7 @@ class Datasources(Endpoint): def __init__(self, parent_srv): super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): @@ -213,3 +217,19 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None, new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) return new_datasource + server_response = self.post_request(url, xml_request, content_type) + new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + return new_datasource + + @api(version='2.0') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='2.0') + def update_permission(self, item, permission_item): + self._permissions.update(item, permission_item) + + @api(version='2.0') + def delete_permission(self, item, capability_item): + self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py new file mode 100644 index 000000000..f2e48db7a --- /dev/null +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -0,0 +1,79 @@ +import logging + +from .. import RequestFactory +from ...models import PermissionsRule + +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError + + +logger = logging.getLogger(__name__) + + +class _DefaultPermissionsEndpoint(Endpoint): + ''' Adds default-permission model to another endpoint + + Tableau default-permissions model applies only to databases and projects + and then takes an object type in the uri to set the defaults. + This class is meant to be instantated inside a parent endpoint which + has these supported endpoints + ''' + def __init__(self, parent_srv, owner_baseurl): + super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + + # owner_baseurl is the baseurl of the parent. The MUST be a lambda + # since we don't know the full site URL until we sign in. If + # populated without, we will get a sign-in error + self.owner_baseurl = owner_baseurl + + def update_default_permissions(self, resource, permissions, content_type): + url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), resource.id, content_type) + update_req = RequestFactory.Permission.add_req(permissions) + response = self.put_request(url, update_req) + permissions = PermissionsRule.from_response(response.content, + self.parent_srv.namespace) + logger.info('Updated permissions for resource {0}'.format(resource.id)) + + return permissions + + def delete_default_permission(self, resource, rule, content_type): + for capability, mode in rule.capabilities.items(): + # Made readibility better but line is too long, will make this look better + url = '{baseurl}/{content_id}/default-permissions/\ + {content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}'.format( + baseurl=self.owner_baseurl(), + content_id=resource.id, + content_type=content_type, + grantee_type=rule.grantee.tag_name + 's', + grantee_id=rule.grantee.id, + cap=capability, + mode=mode) + + logger.debug('Removing {0} permission for capabilty {1}'.format( + mode, capability)) + + self.delete_request(url) + + logger.info('Deleted permission for {0} {1} item {2}'.format( + rule.grantee.tag_name, + rule.grantee.id, + resource.id)) + + def populate_default_permissions(self, item, content_type): + if not item.id: + error = "Server item is missing ID. Item must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def permission_fetcher(): + return self._get_default_permissions(item, content_type) + + item._set_default_permissions(permission_fetcher, content_type) + logger.info('Populated {0} permissions for item (ID: {1})'.format(item.id, content_type)) + + def _get_default_permissions(self, item, content_type, req_options=None): + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") + server_response = self.get_request(url, req_options) + permissions = PermissionsRule.from_response(server_response.content, + self.parent_srv.namespace) + + return permissions diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index f16c9f8df..8c7e93607 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,5 +1,6 @@ from .exceptions import ServerResponseError, InternalServerError from functools import wraps +from xml.etree.ElementTree import ParseError import logging @@ -65,7 +66,11 @@ def _check_status(self, server_response): if server_response.status_code >= 500: raise InternalServerError(server_response) elif server_response.status_code not in Success_codes: - raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) + try: + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) + except ParseError: + # not an xml error + raise NonXMLResponseError(server_response.content) def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 0648d8814..757ca5552 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -46,6 +46,10 @@ class ItemTypeNotAllowed(Exception): pass +class NonXMLResponseError(Exception): + pass + + class GraphQLError(Exception): def __init__(self, error_payload): self.error = error_payload diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py new file mode 100644 index 000000000..6405f96a0 --- /dev/null +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -0,0 +1,83 @@ +import logging + +from .. import RequestFactory, PermissionsRule + +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError + + +logger = logging.getLogger(__name__) + + +class _PermissionsEndpoint(Endpoint): + ''' Adds permission model to another endpoint + + Tableau permissions model is identical between objects but they are nested under + the parent object endpoint (i.e. permissions for workbooks are under + /workbooks/:id/permission). This class is meant to be instantated inside a + parent endpoint which has these supported endpoints + ''' + def __init__(self, parent_srv, owner_baseurl): + super(_PermissionsEndpoint, self).__init__(parent_srv) + + # owner_baseurl is the baseurl of the parent. The MUST be a lambda + # since we don't know the full site URL until we sign in. If + # populated without, we will get a sign-in error + self.owner_baseurl = owner_baseurl + + def update(self, resource, permissions): + url = '{0}/{1}/permissions'.format(self.owner_baseurl(), resource.id) + update_req = RequestFactory.Permission.add_req(permissions) + response = self.put_request(url, update_req) + permissions = PermissionsRule.from_response(response.content, + self.parent_srv.namespace) + logger.info('Updated permissions for resource {0}'.format(resource.id)) + + return permissions + + def delete(self, resource, rules): + # Delete is the only endpoint that doesn't take a list of rules + # so let's fake it to keep it consistent + # TODO that means we need error handling around the call + if isinstance(rules, PermissionsRule): + rules = [rules] + + for rule in rules: + for capability, mode in rule.capabilities.items(): + " /permissions/groups/group-id/capability-name/capability-mode" + url = '{0}/{1}/permissions/{2}/{3}/{4}/{5}'.format( + self.owner_baseurl(), + resource.id, + rule.grantee.permissions_grantee_type + 's', + rule.grantee.id, + capability, + mode) + + logger.debug('Removing {0} permission for capabilty {1}'.format( + mode, capability)) + + self.delete_request(url) + + logger.info('Deleted permission for {0} {1} item {2}'.format( + rule.grantee.permissions_grantee_type, + rule.grantee.id, + resource.id)) + + def populate(self, item): + if not item.id: + error = "Server item is missing ID. Item must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def permission_fetcher(): + return self._get_permissions(item) + + item._set_permissions(permission_fetcher) + logger.info('Populated permissions for item (ID: {0})'.format(item.id)) + + def _get_permissions(self, item, req_options=None): + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + server_response = self.get_request(url, req_options) + permissions = PermissionsRule.from_response(server_response.content, + self.parent_srv.namespace) + + return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 8157e1f59..e4dafcbcc 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,12 +1,22 @@ -from .endpoint import Endpoint, api +from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, ProjectItem, PaginationItem +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from .. import RequestFactory, ProjectItem, PaginationItem, PermissionsRule, Permission + import logging logger = logging.getLogger('tableau.endpoint.projects') class Projects(Endpoint): + def __init__(self, parent_srv): + super(Projects, self).__init__(parent_srv) + + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) + @property def baseurl(self): return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @@ -50,3 +60,51 @@ def create(self, project_item): new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Created new project (ID: {0})'.format(new_project.id)) return new_project + + @api(version='2.0') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='2.0') + def update_permission(self, item, rules): + return self._permissions.update(item, rules) + + @api(version='2.0') + def delete_permission(self, item, rules): + return self._permissions.delete(item, rules) + + @api(version='2.1') + def populate_workbook_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook) + + @api(version='2.1') + def populate_datasource_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource) + + @api(version='3.4') + def populate_flow_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) + + @api(version='2.1') + def update_workbook_default_permissions(self, item): + self._default_permissions.update_default_permissions(item, Permission.Resource.Workbook) + + @api(version='2.1') + def update_datasource_default_permissions(self, item): + self._default_permissions.update_default_permissions(item, Permission.Resource.Datasource) + + @api(version='3.4') + def update_flow_default_permissions(self, item): + self._default_permissions.update_default_permissions(item, Permission.Resource.Flow) + + @api(version='2.1') + def delete_workbook_default_permissions(self, item): + self._default_permissions.delete_default_permissions(item, Permission.Resource.Workbook) + + @api(version='2.1') + def delete_datasource_default_permissions(self, item): + self._default_permissions.delete_default_permissions(item, Permission.Resource.Datasource) + + @api(version='3.4') + def delete_flow_default_permissions(self, item): + self._default_permissions.delete_default_permissions(item, Permission.Resource.Flow) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 772ed79b9..445b0ccde 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,5 +1,8 @@ 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem @@ -25,6 +28,7 @@ class Workbooks(Endpoint): def __init__(self, parent_srv): super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): @@ -216,6 +220,18 @@ def _get_wb_preview_image(self, workbook_item): preview_image = server_response.content return preview_image + @api(version='2.0') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='2.0') + def update_permissions(self, resource, rules): + return self._permissions.update(resource, rules) + + @api(version='2.0') + def delete_permission(self, item, capability_item): + return self._permissions.delete(item, capability_item) + # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") @parameter_added_in(as_job='3.0') diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6a3f811b6..b6739af6b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -5,6 +5,8 @@ from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from ..models import UserItem, GroupItem, PermissionsRule + def _add_multipart(parts): mime_multipart_parts = list() @@ -145,32 +147,26 @@ def update_req(self, group_item, default_site_role): class PermissionRequest(object): - def _add_capability(self, parent_element, capability_set, mode): - for capability_item in capability_set: - capability_element = ET.SubElement(parent_element, 'capability') - capability_element.attrib['name'] = capability_item - capability_element.attrib['mode'] = mode - - def add_req(self, permission_item): + def add_req(self, rules): xml_request = ET.Element('tsRequest') permissions_element = ET.SubElement(xml_request, 'permissions') - for user_capability in permission_item.user_capabilities: - grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities') - grantee_capabilities_element = ET.SubElement(grantee_element, user_capability.User) - grantee_capabilities_element.attrib['id'] = user_capability.grantee_id - capabilities_element = ET.SubElement(grantee_element, 'capabilities') - self._add_capability(capabilities_element, user_capability.allowed, user_capability.Allow) - self._add_capability(capabilities_element, user_capability.denied, user_capability.Deny) - - for group_capability in permission_item.group_capabilities: - grantee_element = ET.SubElement(permissions_element, 'granteeCapabilities') - ET.SubElement(grantee_element, group_capability, id=group_capability.grantee_id) - capabilities_element = ET.SubElement(grantee_element, 'capabilities') - self._add_capability(capabilities_element, group_capability.allowed, group_capability.Allow) - self._add_capability(capabilities_element, group_capability.denied, group_capability.Deny) + for rule in rules: + grantee_capabilities_element = ET.SubElement(permissions_element, 'granteeCapabilities') + grantee_element = ET.SubElement(grantee_capabilities_element, rule.grantee.tag_name) + grantee_element.attrib['id'] = rule.grantee.id + + capabilities_element = ET.SubElement(grantee_capabilities_element, 'capabilities') + self._add_all_capabilities(capabilities_element, rule.capabilities) + return ET.tostring(xml_request) + def _add_all_capabilities(self, capabilities_element, capabilities_map): + for name, mode in capabilities_map.items(): + capability_element = ET.SubElement(capabilities_element, 'capability') + capability_element.attrib['name'] = name + capability_element.attrib['mode'] = mode + class ProjectRequest(object): def update_req(self, project_item): diff --git a/test/assets/datasource_populate_permissions.xml b/test/assets/datasource_populate_permissions.xml new file mode 100644 index 000000000..db967f4a9 --- /dev/null +++ b/test/assets/datasource_populate_permissions.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/project_populate_permissions.xml b/test/assets/project_populate_permissions.xml new file mode 100644 index 000000000..7a49391af --- /dev/null +++ b/test/assets/project_populate_permissions.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_populate_workbook_default_permissions.xml b/test/assets/project_populate_workbook_default_permissions.xml new file mode 100644 index 000000000..e6f3804be --- /dev/null +++ b/test/assets/project_populate_workbook_default_permissions.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_populate_permissions.xml b/test/assets/workbook_populate_permissions.xml new file mode 100644 index 000000000..57517d719 --- /dev/null +++ b/test/assets/workbook_populate_permissions.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_update_permissions.xml b/test/assets/workbook_update_permissions.xml new file mode 100644 index 000000000..fffd90491 --- /dev/null +++ b/test/assets/workbook_update_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 0563d2af7..fdf3c2e51 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -13,6 +13,7 @@ GET_EMPTY_XML = 'datasource_get_empty.xml' GET_BY_ID_XML = 'datasource_get_by_id.xml' POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' +POPULATE_PERMISSIONS_XML = 'datasource_populate_permissions.xml' PUBLISH_XML = 'datasource_publish.xml' PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' UPDATE_XML = 'datasource_update.xml' @@ -181,6 +182,32 @@ def test_update_connection(self): self.assertEquals('9876', new_connection.server_port) self.assertEqual('foo', new_connection.username) + def test_populate_permissions(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + single_datasource = TSC.DatasourceItem('test') + single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.datasources.populate_permissions(single_datasource) + permissions = single_datasource.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }) + + self.assertEqual(permissions[1].grantee.tag_name, 'user') + self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions[1].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }) + def test_publish(self): response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: diff --git a/test/test_project.py b/test/test_project.py index c0958f761..6e055e50f 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -3,11 +3,15 @@ import requests_mock import tableauserverclient as TSC +from ._utils import read_xml_asset, read_xml_assets, asset + TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') -GET_XML = os.path.join(TEST_ASSET_DIR, 'project_get.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'project_update.xml') -CREATE_XML = os.path.join(TEST_ASSET_DIR, 'project_create.xml') +GET_XML = asset('project_get.xml') +UPDATE_XML = asset('project_update.xml') +CREATE_XML = asset('project_create.xml') +POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml' +POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml' class ProjectTests(unittest.TestCase): @@ -97,3 +101,54 @@ def test_create(self): def test_create_missing_name(self): self.assertRaises(ValueError, TSC.ProjectItem, '') + + def test_populate_permissions(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + single_project = TSC.ProjectItem('Project3') + single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.projects.populate_permissions(single_project) + permissions = single_project.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }) + + def test_populate_workbooks(self): + response_xml = read_xml_asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', + text=response_xml) + single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + + self.server.projects.populate_workbook_default_permissions(single_project) + permissions = single_project.default_workbook_permissions + + rule1 = permissions.pop() + + self.assertEqual('c8f2773a-c83a-11e8-8c8f-33e6d787b506', rule1.grantee.id) + self.assertEqual('group', rule1.grantee.tag_name) + self.assertDictEqual(rule1.capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + }) diff --git a/test/test_sort.py b/test/test_sort.py index 17a69e900..a6a57497d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -57,8 +57,8 @@ def test_filter_in(self): request_object=opts, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') + self.assertDictEqual(resp.request.qs, {'pagenumber': ['13'], 'pagesize': [ + '13'], 'filter': ['tags:in:[stocks,market]']}) def test_sort_asc(self): with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index ae814c0b2..0317ba115 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -7,6 +7,10 @@ from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.group_item import GroupItem + from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -17,12 +21,14 @@ GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_permissions.xml') POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') class WorkbookTests(unittest.TestCase): @@ -270,6 +276,66 @@ def test_populate_connections(self): self.assertEqual('4506225a-0d32-4ab1-82d3-c24e85f7afba', single_workbook.connections[0].datasource_id) self.assertEqual('World Indicators', single_workbook.connections[0].datasource_name) + def test_populate_permissions(self): + with open(POPULATE_PERMISSIONS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml) + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + self.server.workbooks.populate_permissions(single_workbook) + permissions = single_workbook.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow + }) + + self.assertEqual(permissions[1].grantee.tag_name, 'user') + self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions[1].capabilities, { + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny + }) + + def test_add_permissions(self): + with open(UPDATE_PERMISSIONS, 'rb') as f: + response_xml = f.read().decode('utf-8') + + single_workbook = TSC.WorkbookItem('test') + single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + PermissionsRule(bob, {'Write': 'Allow'}), + PermissionsRule(group_of_people, {'Read': 'Deny'}) + ] + + with requests_mock.mock() as m: + m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = self.server.workbooks.update_permissions(single_workbook, new_permissions) + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny + }) + + self.assertEqual(permissions[1].grantee.tag_name, 'user') + self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions[1].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow + }) + def test_populate_connections_missing_id(self): single_workbook = TSC.WorkbookItem('test') self.assertRaises(TSC.MissingRequiredFieldError, From c5afbd6a4a65332d851d245830165ba8cd40831d Mon Sep 17 00:00:00 2001 From: Tomasz Machalski Date: Tue, 30 Jul 2019 22:43:14 +0200 Subject: [PATCH 092/567] Fix support for tags in ViewItem (#470) Looks Good! --- tableauserverclient/models/view_item.py | 7 +++++++ test/assets/view_get.xml | 4 ++++ test/test_view.py | 1 + 3 files changed, 12 insertions(+) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index c4d36a841..3dd9e065b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,6 +1,7 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError +from .tag_item import TagItem class ViewItem(object): @@ -119,6 +120,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) owner_elem = view_xml.find('.//t:owner', namespaces=ns) project_elem = view_xml.find('.//t:project', namespaces=ns) + tags_elem = view_xml.find('.//t:tags', namespaces=ns) view_item._created_at = parse_datetime(view_xml.get('createdAt', None)) view_item._updated_at = parse_datetime(view_xml.get('updatedAt', None)) view_item._id = view_xml.get('id', None) @@ -142,5 +144,10 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): elif workbook_elem is not None: view_item._workbook_id = workbook_elem.get('id', None) + if tags_elem is not None: + tags = TagItem.from_xml_element(tags_elem, ns) + view_item.tags = tags + view_item._initial_tags = tags + all_view_items.append(view_item) return all_view_items diff --git a/test/assets/view_get.xml b/test/assets/view_get.xml index f205edf6a..283488a4b 100644 --- a/test/assets/view_get.xml +++ b/test/assets/view_get.xml @@ -6,6 +6,10 @@ + + + + diff --git a/test/test_view.py b/test/test_view.py index 213ea2538..fcf7d986c 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -42,6 +42,7 @@ def test_get(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) + self.assertEqual(set(['tag1', 'tag2']), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) From f572aaf830f41752bcef7ed2b108e6362b16a23e Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 30 Jul 2019 13:47:47 -0700 Subject: [PATCH 093/567] Revert "comments" (#467) Reverting the change --- samples/refresh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/refresh.py b/samples/refresh.py index 437a5ffa1..58e3110f3 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to trigger a refresh on a datasource or workbook +# This script demonstrates how to use trigger a refresh on a datasource or workbook # # To run the script, you must have installed Python 2.7.X or 3.3 and later. #### From f21df16b70b36f432b2c3c432ef94cfccc913e5d Mon Sep 17 00:00:00 2001 From: dzucker-tab <49076749+dzucker-tab@users.noreply.github.com> Date: Wed, 7 Aug 2019 10:38:57 -0700 Subject: [PATCH 094/567] removing doc files and adding readme to direct users to the gh-pages branch for doc source (#478) --- docs/docs/api-ref.md | 3118 ----------------------- docs/docs/dev-guide.md | 102 - docs/docs/filter-sort.md | 90 - docs/docs/index.md | 76 - docs/docs/page-through-results.md | 69 - docs/docs/populate-connections-views.md | 58 - docs/docs/readme.md | 3 + docs/docs/samples.md | 58 - docs/docs/search.md | 5 - docs/docs/sign-in-out.md | 70 - docs/docs/versions.md | 51 - 11 files changed, 3 insertions(+), 3697 deletions(-) delete mode 100644 docs/docs/api-ref.md delete mode 100644 docs/docs/dev-guide.md delete mode 100644 docs/docs/filter-sort.md delete mode 100644 docs/docs/index.md delete mode 100644 docs/docs/page-through-results.md delete mode 100644 docs/docs/populate-connections-views.md create mode 100644 docs/docs/readme.md delete mode 100644 docs/docs/samples.md delete mode 100644 docs/docs/search.md delete mode 100644 docs/docs/sign-in-out.md delete mode 100644 docs/docs/versions.md diff --git a/docs/docs/api-ref.md b/docs/docs/api-ref.md deleted file mode 100644 index c75ddeffb..000000000 --- a/docs/docs/api-ref.md +++ /dev/null @@ -1,3118 +0,0 @@ ---- -title: API reference -layout: docs ---- - -
- Important: More coming soon! This section is under active construction and might not reflect all the available functionality of the TSC library. - -
- - - -The Tableau Server Client (TSC) is a Python library for the Tableau Server REST API. Using the TSC library, you can manage and change many of the Tableau Server and Tableau Online resources programmatically. You can use this library to create your own custom applications. - -The TSC API reference is organized by resource. The TSC library is modeled after the REST API. The methods, for example, `workbooks.get()`, correspond to the endpoints for resources, such as [workbooks](#workbooks), [users](#users), [views](#views), and [data sources](#data-sources). The model classes (for example, the [WorkbookItem class](#workbookitem-class) have attributes that represent the fields (`name`, `id`, `owner_id`) that are in the REST API request and response packages, or payloads. - -|:--- | -| **Note:** Some methods and features provided in the REST API might not be currently available in the TSC library (and in some cases, the opposite is true). In addition, the same limitations apply to the TSC library that apply to the REST API with respect to resources on Tableau Server and Tableau Online. For more information, see the [Tableau Server REST API Reference](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#API_Reference%3FTocPath%3DAPI%2520Reference%7C_____0){:target="_blank"}.| - - - -* TOC -{:toc } - -
-
- - -## Authentication - -You can use the TSC library to sign in and sign out of Tableau Server and Tableau Online. The credentials for signing in are defined in the `TableauAuth` class and they correspond to the attributes you specify when you sign in using the Tableau Server REST API. - -
-
- -### TableauAuth class - -```py -TableauAuth(username, password, site_id='', user_id_to_impersonate=None) -``` -The `TableauAuth` class defines the information you can set in a sign-in request. The class members correspond to the attributes of a server request or response payload. To use this class, create a new instance, supplying user name, password, and site information if necessary, and pass the request object to the [Auth.sign_in](#auth.sign-in) method. - - - **Note:** In the future, there might be support for additional forms of authorization and authentication (for example, OAuth). - -**Attributes** - -Name | Description -:--- | :--- -`username` | The name of the user whose credentials will be used to sign in. -`password` | The password of the user. -`site_id` | This corresponds to the `contentUrl` attribute in the Tableau REST API. The `site_id` is the portion of the URL that follows the `/site/` in the URL. For example, "MarketingTeam" is the `site_id` in the following URL *MyServer*/#/site/**MarketingTeam**/projects. To specify the default site on Tableau Server, you can use an empty string `''` (single quotes, no space). For Tableau Online, you must provide a value for the `site_id`. -`user_id_to_impersonate` | Specifies the id (not the name) of the user to sign in as. - -Source file: models/tableau_auth.py - -**Example** - -```py -import tableauserverclient as TSC -# create a new instance of a TableauAuth object for authentication - -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') - -# create a server instance -# pass the "tableau_auth" object to the server.auth.sign_in() method -``` - -
-
- -### Auth methods -The Tableau Server Client provides two methods for interacting with authentication resources. These methods correspond to the sign in and sign out endpoints in the Tableau Server REST API. - - -Source file: server/endpoint/auth_endpoint.py - -
-
- -#### auth.sign in - -```py -auth.sign_in(auth_req) -``` - -Signs you in to Tableau Server. - - -The method signs into Tableau Server or Tableau Online and manages the authentication token. You call this method from the server object you create. For information about the server object, see [Server](#server). The authentication token keeps you signed in for 240 minutes, or until you call the `auth.sign_out` method. Before you use this method, you first need to create the sign-in request (`auth_req`) object by creating an instance of the `TableauAuth`. To call this method, create a server object for your server. For more information, see [Sign in and Out](sign-in-out). - -REST API: [Sign In](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____77){:target="_blank"} - -**Parameters** - -`auth_req` : The `TableauAuth` object that holds the sign-in credentials for the site. - - -**Example** - -```py -import tableauserverclient as TSC - -# create an auth object -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') - -# create an instance for your server -server = TSC.Server('http://SERVER_URL') - -# call the sign-in method with the auth object -server.auth.sign_in(tableau_auth) - -``` - - -**See Also** -[Sign in and Out](sign-in-out) -[Server](#server) - -
-
- - -#### auth.sign out - -```py -auth.sign_out() -``` -Signs you out of the current session. - -The `sign_out()` method takes care of invalidating the authentication token. For more information, see [Sign in and Out](sign-in-out). - -REST API: [Sign Out](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_Out%3FTocPath%3DAPI%2520Reference%7C_____78){:target="_blank"} - -**Example** - -```py - -server.auth.sign_out() - - -``` - - - - -**See Also** -[Sign in and Out](sign-in-out) -[Server](#server) - -
-
- - - - -## Connections - -The connections for Tableau Server data sources and workbooks are represented by a `ConnectionItem` class. You can call data source and workbook methods to query or update the connection information. The `ConnectionCredentials` class represents the connection information you can update. - -### ConnectionItem class - -```py -ConnectionItem() -``` - -The `ConnectionItem` class corresponds to workbook and data source connections. - -In the Tableau Server REST API, there are separate endpoints to query and update workbook and data source connections. - -**Attributes** - -Name | Description - :--- | : --- -`datasource_id` | The identifier of the data source. -`datasource_name` | The name of the data source. -`id` | The identifier of the connection. -`connection_type` | The type of connection. -`username` | The username for the connection. -`password` | The password used for the connection. -`embed_password` | (Boolean) Determines whether to embed the password (`True`) for the workbook or data source connection or not (`False`). -`server_address` | The server address for the connection. -`server_port` | The port used for the connection. - -Source file: models/connection_item.py - -
-
- - - -### ConnectionCredentials class - -```py -ConnectionCredentials(name, password, embed=True, oauth=False) -``` - - -The `ConnectionCredentials` class is used for workbook and data source publish requests. - - - -**Attributes** - -Attribute | Description -:--- | :--- -`name` | The username for the connection. -`embed_password` | (Boolean) Determines whether to embed the passowrd (`True`) for the workbook or data source connection or not (`False`). -`password` | The password used for the connection. -`server_address` | The server address for the connection. -`server_port` | The port used by the server. -`ouath` | (Boolean) Specifies whether OAuth is used for the data source of workbook connection. For more information, see [OAuth Connections](https://onlinehelp.tableau.com/current/server/en-us/protected_auth.htm?Highlight=oauth%20connections){:target="_blank"}. - - -Source file: models/connection_credentials.py - -
-
- -## Data sources - -Using the TSC library, you can get all the data sources on a site, or get the data sources for a specific project. -The data source resources for Tableau Server are defined in the `DatasourceItem` class. The class corresponds to the data source resources you can access using the Tableau Server REST API. For example, you can gather information about the name of the data source, its type, its connections, and the project it is associated with. The data source methods are based upon the endpoints for data sources in the REST API and operate on the `DatasourceItem` class. - -
- -### DatasourceItem class - -```py -DatasourceItem(project_id, name=None) -``` - -The `DatasourceItem` represents the data source resources on Tableau Server. This is the information that can be sent or returned in the response to an REST API request for data sources. When you create a new `DatasourceItem` instance, you must specify the `project_id` that the data source is associated with. - -**Attributes** - -Name | Description -:--- | :--- -`connections` | The list of data connections (`ConnectionItem`) for the specified data source. You must first call the `populate_connections` method to access this data. See the [ConnectionItem class](#connectionitem-class). -`content_url` | The name of the data source as it would appear in a URL. -`created_at` | The date and time when the data source was created. -`datasource_type` | The type of data source, for example, `sqlserver` or `excel-direct`. -`id` | The identifier for the data source. You need this value to query a specific data source or to delete a data source with the `get_by_id` and `delete` methods. -`name` | The name of the data source. If not specified, the name of the published data source file is used. -`project_id` | The identifier of the project associated with the data source. When you must provide this identifier when create an instance of a `DatasourceItem` -`project_name` | The name of the project associated with the data source. -`tags` | The tags that have been added to the data source. This is a list of strings, e.g ["tag"]. -`updated_at` | The date and time when the data source was last updated. - - -**Example** - -```py - import tableauserverclient as TSC - - # Create new datasource_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' - - new_datasource = TSC.DatasourceItem('3a8b6148-493c-11e6-a621-6f3499394a39') -``` - - -Source file: models/datasource_item.py - -
-
- -### Datasources methods - -The Tableau Server Client provides several methods for interacting with data source resources, or endpoints. These methods correspond to endpoints in the Tableau Server REST API. - -Source file: server/endpoint/datasources_endpoint.py - -
-
- -#### datasources.delete - -```py -datasources.delete(datasource_id) -``` - -Removes the specified data source from Tableau Server. - - -**Parameters** - -Name | Description -:--- | :--- -`datasource_id` | The identifier (`id`) for the `DatasourceItem` that you want to delete from the server. - - -**Exceptions** - -Error | Description - :--- | : --- -`Datasource ID undefined` | Raises an exception if a valid `datasource_id` is not provided. - - -REST API: [Delete Datasource](http://onlinehelp.tableau.com/v0.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Delete_Datasource%3FTocPath%3DAPI%2520Reference%7C_____19){:target="_blank"} - -
-
- - -#### datasources.download - -```py -datasources.download(datasource_id, filepath=None, no_extract=False) - -``` -Downloads the specified data source in `.tdsx` format. - -REST API: [Download Datasource](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Download_Datasource%3FTocPath%3DAPI%2520Reference%7C_____34){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`datasource_id` | The identifier (`id`) for the `DatasourceItem` that you want to download from the server. -`filepath` | (Optional) Downloads the file to the location you specify. If no location is specified (the default is `Filepath=None`), the file is downloaded to the current working directory. -`no_extract` | (Optional) Specifies whether to download the file without the extract. When the data source has an extract, if you set the parameter `no_extract=True`, the extract is not included. You can use this parameter to improve performance if you are downloading data sources that have large extracts. The default is to include the extract, if present (`no_extract=False`). Available starting with Tableau Server REST API version 2.5. - -**Exceptions** - -Error | Description -:--- | :--- -`Datasource ID undefined` | Raises an exception if a valid `datasource_id` is not provided. - - -**Returns** - -The file path to the downloaded data source. The data source is downloaded in `.tdsx` format. - -**Example** - -```py - - file_path = server.datasources.download('1a2a3b4b-5c6c-7d8d-9e0e-1f2f3a4a5b6b') - print("\nDownloaded the file to {0}.".format(file_path)) - -```` - - -
-
- -#### datasources.get - -```py -datasources.get(req_options=None) -``` - -Returns all the data sources for the site. - -To get the connection information for each data source, you must first populate the `DatasourceItem` with connection information using the [populate_connections(*datasource_item*)](#populate-connections-datasource) method. For more information, see [Populate Connections and Views](populate-connections-views#populate-connections-for-data-sources) - -REST API: [Query Datasources](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Datasources%3FTocPath%3DAPI%2520Reference%7C_____49){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`req_option` | (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific data source, you could specify the name of the project or its id. - - -**Returns** - -Returns a list of `DatasourceItem` objects and a `PaginationItem` object. Use these values to iterate through the results. - - - - -**Example** - -```py -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVERURL') - -with server.auth.sign_in(tableau_auth): - all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) - print([datasource.name for datasource in all_datasources]) -```` - - - -
-
- - -#### datasources.get_by_id - -```py -datasources.get_by_id(datasource_id) -``` - -Returns the specified data source item. - -REST API: [Query Datasource](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Datasource%3FTocPath%3DAPI%2520Reference%7C_____46){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`datasource_id` | The `datasource_id` specifies the data source to query. - - -**Exceptions** - -Error | Description -:--- | :--- -`Datasource ID undefined` | Raises an exception if a valid `datasource_id` is not provided. - - -**Returns** - -The `DatasourceItem`. See [DatasourceItem class](#datasourceitem-class) - - -**Example** - -```py - -datasource = server.datasources.get_by_id('59a57c0f-3905-4022-9e87-424fb05e9c0e') -print(datasource.name) - -``` - - -
-
- - - -#### datasources.populate_connections - -```py -datasources.populate_connections(datasource_item) -``` - - -Populates the connections for the specified data source. - -This method retrieves the connection information for the specified data source. The REST API is designed to return only the information you ask for explicitly. When you query for all the data sources, the connection information is not included. Use this method to retrieve the connections. The method adds the list of data connections to the data source item (`datasource_item.connections`). This is a list of `ConnectionItem` objects. - -REST API: [Query Datasource Connections](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Datasource_Connections%3FTocPath%3DAPI%2520Reference%7C_____47){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`datasource_item` | The `datasource_item` specifies the data source to populate with connection information. - - - - -**Exceptions** - -Error | Description -:--- | :--- -`Datasource item missing ID. Datasource must be retrieved from server first.` | Raises an error if the datasource_item is unspecified. - - -**Returns** - -None. A list of `ConnectionItem` objects are added to the data source (`datasource_item.connections`). - - -**Example** - -```py -# import tableauserverclient as TSC - -# server = TSC.Server('http://SERVERURL') -# - ... - -# get the data source - datasource = server.datasources.get_by_id('1a2a3b4b-5c6c-7d8d-9e0e-1f2f3a4a5b6b') - - -# get the connection information - server.datasources.populate_connections(datasource) - -# print the information about the first connection item - print(datasource.connections[0].connection_type) - print(datasource.connections[0].id) - print(datasource.connections[0].server_address) - - ... - -``` - - -
-
- -#### datasources.publish - -```py -datasources.publish(datasource_item, file_path, mode, connection_credentials=None) -``` - -Publishes a data source to a server, or appends data to an existing data source. - -This method checks the size of the data source and automatically determines whether the publish the data source in multiple parts or in one opeation. - -REST API: [Publish Datasource](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Publish_Datasource%3FTocPath%3DAPI%2520Reference%7C_____44){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`datasource_item` | The `datasource_item` specifies the new data source you are adding, or the data source you are appending to. If you are adding a new data source, you need to create a new `datasource_item` with a `project_id` of an existing project. The name of the data source will be the name of the file, unless you also specify a name for the new data source when you create the instance. See [DatasourceItem](#datasourceitem-class). -`file_path` | The path and name of the data source to publish. -`mode` | Specifies whether you are publishing a new data source (`CreateNew`), overwriting an existing data source (`Overwrite`), or appending data to a data source (`Append`). If you are appending to a data source, the data source on the server and the data source you are publishing must be be extracts (.tde files) and they must share the same schema. You can also use the publish mode attributes, for example: `TSC.Server.PublishMode.Overwrite`. -`connection_credentials` | (Optional) The credentials required to connect to the data source. The `ConnectionCredentials` object contains the authentication information for the data source (user name and password, and whether the credentials are embeded or OAuth is used). - - - -**Exceptions** - -Error | Description -:--- | :--- -`File path does not lead to an existing file.` | Raises an error of the file path is incorrect or if the file is missing. -`Invalid mode defined.` | Raises an error if the publish mode is not one of the defined options. -`Only .tds, tdsx, or .tde files can be published as datasources.` | Raises an error if the type of file specified is not supported. - - -**Returns** - -The `DatasourceItem` for the data source that was added or appended to. - - -**Example** - -```py - - import tableauserverclient as TSC - server = TSC.Server('http://SERVERURL') - - ... - - project_id = '3a8b6148-493c-11e6-a621-6f3499394a39' - file_path = r'C:\temp\WorldIndicators.tde' - - - # Use the project id to create new datsource_item - new_datasource = TSC.DatasourceItem(project_id) - - # publish data source (specified in file_path) - new_datasource = server.datasources.publish( - new_datasource, file_path, 'CreateNew') - - ... -``` - -
-
- -#### datasources.update - -```py -datasource.update(datasource_item) -``` - -Updates the owner, or project of the specified data source. - -REST API: [Update Datasource](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Datasource%3FTocPath%3DAPI%2520Reference%7C_____79){:target="_blank"} - -**Parameters** - -Name | Description - :--- | : --- -`datasource_item` | The `datasource_item` specifies the data source to update. - - - -**Exceptions** - -Error | Description - :--- | : --- -`Datasource item missing ID. Datasource must be retrieved from server first.` | Raises an error if the datasource_item is unspecified. Use the `Datasources.get()` method to retrieve that identifies for the data sources on the server. - - -**Returns** - -An updated `DatasourceItem`. - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('http://SERVERURL') -# sign in ... - -# get the data source item to update - datasource = server.datasources.get_by_id('1a2a3b4b-5c6c-7d8d-9e0e-1f2f3a4a5b6b') - -# do some updating - datasource.name = 'New Name' - -# call the update method with the data source item - updated_datasource = server.datasources.update(datasource) - - - -``` - - - -
-
- -## Filters - -The TSC library provides a `Filter` class that you can use to filter results returned from the server. - -You can use the `Filter` and `RequestOptions` classes to filter and sort the following endpoints: - -- Users -- Datasources -- Workbooks -- Views - -For more information, see [Filter and Sort](filter-sort). - - -### Filter class - -```py -Filter(field, operator, value) -``` - -The `Filter` class corresponds to the *filter expressions* in the Tableau REST API. - - - -**Attributes** - -Name | Description -:--- | :--- -`Field` | Defined in the `RequestOptions.Field` class. -`Operator` | Defined in the `RequestOptions.Operator` class -`Value` | The value to compare with the specified field and operator. - - - - - -
-
- - -## Groups - -Using the TSC library, you can get information about all the groups on a site, you can add or remove groups, or add or remove users in a group. - -The group resources for Tableau Server are defined in the `GroupItem` class. The class corresponds to the group resources you can access using the Tableau Server REST API. The group methods are based upon the endpoints for groups in the REST API and operate on the `GroupItem` class. - -
-
- -### GroupItem class - -```py -GroupItem(name) -``` - -The `GroupItem` class contains the attributes for the group resources on Tableau Server. The `GroupItem` class defines the information you can request or query from Tableau Server. The class members correspond to the attributes of a server request or response payload. - -Source file: models/group_item.py - -**Attributes** - -Name | Description -:--- | :--- -`domain_name` | The name of the Active Directory domain (`local` if local authentication is used). -`id` | The id of the group. -`users` | The list of users (`UserItem`). -`name` | The name of the group. The `name` is required when you create an instance of a group. - - - -**Example** - -```py - newgroup = TSC.GroupItem('My Group') - - # call groups.create() with new group -``` - - - - -
-
- -### Groups methods - -The Tableau Server Client provides several methods for interacting with group resources, or endpoints. These methods correspond to endpoints in the Tableau Server REST API. - - - -Source file: server/endpoint/groups_endpoint.py - -
-
- -#### groups.add_user - -```py -groups.add_user(group_item, user_id): -``` - -Adds a user to the specified group. - - -REST API [Add User to Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Add_User_to_Group%3FTocPath%3DAPI%2520Reference%7C_____8){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`group_item` | The `group_item` specifies the group to update. -`user_id` | The id of the user. - - - - -**Returns** - -None. - - -**Example** - -```py -# Adding a user to a group -# -# get the group item - all_groups, pagination_item = server.groups.get() - mygroup = all_groups[1] - -# The id for Ian is '59a8a7b6-be3a-4d2d-1e9e-08a7b6b5b4ba' - -# add Ian to the group - server.groups.add_user(mygroup, '59a8a7b6-be3a-4d2d-1e9e-08a7b6b5b4ba') - - - -``` - -
-
- -#### groups.create - -```py -create(group_item) -``` - -Creates a new group in Tableau Server. - - -REST API: [Create Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Create_Group%3FTocPath%3DAPI%2520Reference%7C_____14){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`group_item` | The `group_item` specifies the group to add. You first create a new instance of a `GroupItem` and pass that to this method. - - - - -**Returns** -Adds new `GroupItem`. - - -**Example** - -```py - -# Create a new group - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - -# create a new instance with the group name - newgroup = TSC.GroupItem('My Group') - -# call the create method - newgroup = server.groups.create(newgroup) - -# print the names of the groups on the server - all_groups, pagination_item = server.groups.get() - for group in all_groups : - print(group.name, group.id) -``` - -
-
- -#### groups.delete - -```py -groups.delete(group_id) -``` - -Deletes the group on the site. - -REST API: [Delete Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Remove_User_from_Site%3FTocPath%3DAPI%2520Reference%7C_____74){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`group_id` | The identifier (`id`) for the group that you want to remove from the server. - - -**Exceptions** - -Error | Description -:--- | :--- -`Group ID undefined` | Raises an exception if a valid `group_id` is not provided. - - -**Example** - -```py -# Delete a group from the site - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - server.groups.delete('1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d') - -``` -
-
- -#### groups.update - -```py -groups.update(group_item, default_site_role=UserItem.Roles.Unlicensed) -``` - -Updates the group on the site. -If domain_name = 'local' then update only the name of the group. -If not - update group from the Active Directory with domain_name. - -REST API: [Update Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Group%3FTocPath%3DAPI%2520Reference%7C_____95){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`group_item` | the group_item specifies the group to update. -`default_site_role` | if group updates from Active Directory then this is the default role for the new users. - - -**Exceptions** - -Error | Description -:--- | :--- -`Group item missing ID` | Raises an exception if a valid `group_item.id` is not provided. - - -**Example** - -```py -# Update a group - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - all_groups, pagination_item = server.groups.get() - - for group in all_groups: - server.groups.update(group) -``` -
-
- -#### groups.get - -```py -groups.get(req_options=None) -``` - -Returns information about the groups on the site. - - -To get information about the users in a group, you must first populate the `GroupItem` with user information using the [groups.populate_users](api-ref#groupspopulateusers) method. - - -REST API: [Get Uers on Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Get_Users_on_Site%3FTocPath%3DAPI%2520Reference%7C_____41){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`req_option` | (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific group, you could specify the name of the group or the group id. - - -**Returns** - -Returns a list of `GroupItem` objects and a `PaginationItem` object. Use these values to iterate through the results. - - -**Example** - - -```py -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - - # get the groups on the server - all_groups, pagination_item = server.groups.get() - - # print the names of the first 100 groups - for group in all_groups : - print(group.name, group.id) -```` - - -
-
- -#### groups.populate_users - -```py -groups.populate_users(group_item, req_options=None) -``` - -Populates the `group_item` with the list of users. - - -REST API: [Get Users in Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Get_Users_in_Group){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`group_item` | The `group_item` specifies the group to populate with user information. -`req_options` | (Optional) Additional request options to send to the endpoint. - - - -**Exceptions** - -`Group item missing ID. Group must be retrieved from server first.` : Raises an error if the `group_item` is unspecified. - - -**Returns** - -None. A list of `UserItem` objects are added to the group (`group_item.users`). - - -**Example** - -```py -# import tableauserverclient as TSC - -# server = TSC.Server('http://SERVERURL') -# - ... - -# get the group - all_groups, pagination_item = server.groups.get() - mygroup = all_groups[1] - -# get the user information - pagination_item = server.groups.populate_users(mygroup) - - -# print the names of the users - for user in mygroup.users : - print(user.name) - - - - -``` - -
-
- -#### groups.remove_user - -```py -groups.remove_user(group_item, user_id) -``` - -Removes a user from a group. - - - - -REST API: [Remove User from Group](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Remove_User_from_Group%3FTocPath%3DAPI%2520Reference%7C_____73){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`group_item` | The `group_item` specifies the group to remove the user from. -`user_id` | The id for the user. - - - -**Exceptions** - -Error | Description -:--- | :--- -`Group must be populated with users first.` | Raises an error if the `group_item` is unpopulated. - - -**Returns** - -None. The user is removed from the group. - - -**Example** - -```py -# Remove a user from the group - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - - # get the group - mygroup = server.groups.get_by_id('1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d') - - # remove user '9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d' - server.groups.remove_user(mygroup, '9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - -``` - -
-
- - - -## Projects - -Using the TSC library, you can get information about all the projects on a site, or you can create, update projects, or remove projects. - -The project resources for Tableau are defined in the `ProjectItem` class. The class corresponds to the project resources you can access using the Tableau Server REST API. The project methods are based upon the endpoints for projects in the REST API and operate on the `ProjectItem` class. - - - - - -
- -### ProjectItem class - -```py - -ProjectItem(name, description=None, content_permissions=None, parent_id=None) - -``` -The project resources for Tableau are defined in the `ProjectItem` class. The class corresponds to the project resources you can access using the Tableau Server REST API. - -**Attributes** - -Name | Description -:--- | :--- -`content_permissions` | Sets or shows the permissions for the content in the project. The options are either `LockedToProject` or `ManagedByOwner`. -`name` | Name of the project. -`description` | The description of the project. -`id` | The project id. -`parent_id` | The parent project id. - - - -Source file: models/project_item.py - - -#### ProjectItem.ContentPermissions - -The `ProjectItem` class has a sub-class that defines the permissions for the project (`ProjectItem.ContentPermissions`). The options are `LockedToProject` and `ManagedByOwner`. For information on these content permissions, see [Lock Content Permissions to the Project](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Create_Project%3FTocPath%3DAPI%2520Reference%7C_____15){:target="_blank"} - -Name | Description -:--- | :--- -`ProjectItem.ContentPermissions.LockedToProject` | Locks all content permissions to the project. -`ProjectItem.ContentPermissions.ManagedByOwner` | Users can manage permissions for content that they own. This is the default. - -**Example** - -```py - -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc - - -locked_true = TSC.ProjectItem.ContentPermissions.LockedToProject -print(locked_true) -# prints 'LockedToProject' - -by_owner = TSC.ProjectItem.ContentPermissions.ManagedByOwner -print(by_owner) -# prints 'ManagedByOwner' - - -# pass the content_permissions to new instance of the project item. -new_project = TSC.ProjectItem(name='My Project', content_permissions=by_owner, description='Project example') - -``` - -
-
- -### Project methods - -The project methods are based upon the endpoints for projects in the REST API and operate on the `ProjectItem` class. - - -Source files: server/endpoint/projects_endpoint.py - -
-
- - -#### projects.create - -```py -projects.create(project_item) -``` - - -Creates a project on the specified site. - -To create a project, you first create a new instance of a `ProjectItem` and pass it to the create method. To specify the site to create the new project, create a `TableauAuth` instance using the content URL for the site (`site_id`), and sign in to that site. See the [TableauAuth class](#tableauauth-class). - - -REST API: [Create Project](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Create_Project%3FTocPath%3DAPI%2520Reference%7C_____15){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`project_item` | Specifies the properties for the project. The `project_item` is the request package. To create the request package, create a new instance of `ProjectItem`. - - -**Returns** -Returns the new project item. - - - -**Example** - -```py -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') -server = TSC.Server('http://SERVER') - -with server.auth.sign_in(tableau_auth): - # create project item - new_project = TSC.ProjectItem(name='Example Project', content_permissions='LockedToProject', description='Project created for testing') - # create the project - new_project = server.projects.create(new_project) - -``` - -
-
- - -#### projects.get - -```py -projects.get() - -``` - -Return a list of project items for a site. - - -To specify the site, create a `TableauAuth` instance using the content URL for the site (`site_id`), and sign in to that site. See the [TableauAuth class](#tableauauth-class). - -REST API: [Query Projects](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Projects%3FTocPath%3DAPI%2520Reference%7C_____55){:target="_blank"} - - -**Parameters** - -None. - -**Returns** - -Returns a list of all `ProjectItem` objects and a `PaginationItem`. Use these values to iterate through the results. - - - - **Example** - -```py -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') -server = TSC.Server('http://SERVER') - -with server.auth.sign_in(tableau_auth): - # get all projects on site - all_project_items, pagination_item = server.projects.get() - print([proj.name for proj in all_project_items]) - -``` - -
-
- - -#### projects.update - -```py -projects.update(project_item) -``` - -Modify the project settings. - -You can use this method to update the project name, the project description, or the project permissions. To specify the site, create a `TableauAuth` instance using the content URL for the site (`site_id`), and sign in to that site. See the [TableauAuth class](#tableauauth-class). - -REST API: [Update Project](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Project%3FTocPath%3DAPI%2520Reference%7C_____82){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`project_item` | The project item object must include the project ID. The values in the project item override the current project settings. - - -**Exceptions** - -Error | Description - :--- | : --- -`Project item missing ID.` | Raises an exception if the project item does not have an ID. The project ID is sent to the server as part of the URI. - - -**Returns** - -Returns the updated project information. - -See [ProjectItem class](#projectitem-class) - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc - - ... - # get list of projects - all_project_items, pagination_item = server.projects.get() - - - # update project item #7 with new name, etc. - my_project = all_projects[7] - my_project.name ='New name' - my_project.description = 'New description' - - # call method to update project - updated_project = server.projects.update(my_project) - - - - -``` -
-
- - -#### projects.delete - -```py -projects.delete(project_id) -``` - -Deletes a project by ID. - - -To specify the site, create a `TableauAuth` instance using the content URL for the site (`site_id`), and sign in to that site. See the [TableauAuth class](#tableauauth-class). - - -REST API: [Delete Project](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Delete_Project%3FTocPath%3DAPI%2520Reference%7C_____24){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`project_id` | The ID of the project to delete. - - - - -**Exceptions** - -Error | Description -:--- | :--- -`Project ID undefined.` | Raises an exception if the project item does not have an ID. The project ID is sent to the server as part of the URI. - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc. - - server.projects.delete('1f2f3e4e-5d6d-7c8c-9b0b-1a2a3f4f5e6e') - -``` - - -
-
- - -## Requests - -The TSC library provides a `RequestOptions` class that you can use to filter results returned from the server. - -You can use the `Sort` and `RequestOptions` classes to filter and sort the following endpoints: - -- Users -- Datasources -- Groups -- Workbooks -- Views - -For more information, see [Filter and Sort](filter-sort). - -
- - -### RequestOptions class - -```py -RequestOptions(pagenumber=1, pagesize=100) - -``` - - - -**Attributes** - -Name | Description -:--- | :--- -`pagenumber` | The page number of the returned results. The defauilt value is 1. -`pagesize` | The number of items to return with each page (the default value is 100). -`sort()` | Returns a iterable set of `Sort` objects. -`filter()` | Returns an iterable set of `Filter` objects. - -
-
- - - -#### RequestOptions.Field class - -The `RequestOptions.Field` class corresponds to the fields used in filter expressions in the Tableau REST API. For more information, see [Filtering and Sorting](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_concepts_filtering_and_sorting.htm%3FTocPath%3DConcepts%7C_____7){:target="_blank"} in the Tableau REST API. - -**Attributes** - -**Attributes** - -Field | Description -:--- | :--- -`CreatedAt` | Same as 'createdAt' in the REST API. TSC. `RequestOptions.Field.CreatedAt` -`LastLogin` | Same as 'lastLogin' in the REST API. `RequestOptions.Field.LastLogin` -`Name` | Same as 'name' in the REST API. `RequestOptions.Field.Name` -`OwnerName` | Same as 'ownerName' in the REST API. `RequestOptions.Field.OwnerName` -`SiteRole` | Same as 'siteRole' in the REST API. `RequestOptions.Field.SiteRole` -`Tags` | Same as 'tags' in the REST API. `RequestOptions.Field.Tags` -`UpdatedAt` | Same as 'updatedAt' in the REST API. `RequestOptions.Field.UpdatedAt` - - -
-
- - - -#### RequestOptions.Operator class - -Specifies the operators you can use to filter requests. - - -**Attributes** - -Operator | Description -:--- | :--- -`Equals` | Sets the operator to equals (same as `eq` in the REST API). `TSC.RequestOptions.Operator.Equals` -`GreaterThan` | Sets the operator to greater than (same as `gt` in the REST API). `TSC.RequestOptions.Operator.GreaterThan` -`GreaterThanOrEqual` | Sets the operator to greater than or equal (same as `gte` in the REST API). `TSC.RequestOptions.Operator.GreaterThanOrEqual` -`LessThan` | Sets the operator to less than (same as `lt` in the REST API). `TSC.RequestOptions.Operator.LessThan` -`LessThanOrEqual` | Sets the operator to less than or equal (same as `lte` in the REST API). `TSC.RequestOptions.Operator.LessThanOrEqual` -`In` | Sets the operator to in (same as `in` in the REST API). `TSC.RequestOptions.Operator.In` - -
-
- - - -#### RequestOptions.Direction class - -Specifies the direction to sort the returned fields. - - -**Attributes** - -Name | Description -:--- | :--- -`Asc` | Sets the sort direction to ascending (`TSC.RequestOptions.Direction.Asc`) -`Desc` | Sets the sort direction to descending (`TSC.RequestOptions.Direction.Desc`). - - -
-
- - - -## Server - -In the Tableau REST API, the server (`http://MY-SERVER/`) is the base or core of the URI that makes up the various endpoints or methods for accessing resources on the server (views, workbooks, sites, users, data sources, etc.) -The TSC library provides a `Server` class that represents the server. You create a server instance to sign in to the server and to call the various methods for accessing resources. - - -
-
- - -### Server class - -```py -Server(server_address) -``` -The `Server` class contains the attributes that represent the server on Tableau Server. After you create an instance of the `Server` class, you can sign in to the server and call methods to access all of the resources on the server. - -**Attributes** - -Attribute | Description -:--- | :--- -`server_address` | Specifies the address of the Tableau Server or Tableau Online (for example, `http://MY-SERVER/`). -`version` | Specifies the version of the REST API to use (for example, `'2.5'`). When you use the TSC library to call methods that access Tableau Server, the `version` is passed to the endpoint as part of the URI (`https://MY-SERVER/api/2.5/`). Each release of Tableau Server supports specific versions of the REST API. New versions of the REST API are released with Tableau Server. By default, the value of `version` is set to `'2.3'`, which corresponds to Tableau Server 10.0. You can view or set this value. You might need to set this to a different value, for example, if you want to access features that are supported by the server and a later version of the REST API. For more information, see [REST API Versions](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_concepts_versions.htm){:target="_blank"} - - - -**Example** - -```py -import tableauserverclient as TSC - -# create a instance of server -server = TSC.Server('http://MY-SERVER') - - -# change the REST API version to 2.5 -server.version = '2.5' - - -``` - -#### Server.*Resources* - -When you create an instance of the `Server` class, you have access to the resources on the server after you sign in. You can select these resources and their methods as members of the class, for example: `server.views.get()` - - - -Resource | Description - :--- | : --- -*server*.auth | Sets authentication for sign in and sign out. See [Auth](#auththentication) | -*server*.views | Access the server views and methods. See [Views](#views) -*server*.users | Access the user resources and methods. See [Users](#users) -*server*.sites | Access the sites. See [Sites](#sites) -*server*.groups | Access the groups resources and methods. See [Groups](#groups) -*server*.workbooks | Access the resources and methods for workbooks. See [Workbooks](#workbooks) -*server*.datasources | Access the resources and methods for data sources. See [Data Sources](#data-sources) -*server*.projects | Access the resources and methods for projects. See [Projects](#projets) -*server*.schedules | Access the resources and methods for schedules. See [Schedules](#Schedules) -*server*.server_info | Access the resources and methods for server information. See [ServerInfo class](#serverinfo-class) - -
-
- -#### Server.PublishMode - -The `Server` class has `PublishMode` class that enumerates the options that specify what happens when you publish a workbook or data source. The options are `Overwrite`, `Append`, or `CreateNew`. - - -**Properties** - -Resource | Description - :--- | : --- -`PublishMode.Overwrite` | Overwrites the workbook or data source. -`PublishMode.Append` | Appends to the workbook or data source. -`PublishMode.CreateNew` | Creates a new workbook or data source. - - -**Example** - -```py - - print(TSC.Server.PublishMode.Overwrite) - # prints 'Overwrite' - - overwrite_true = TSC.Server.PublishMode.Overwrite - - ... - - # pass the PublishMode to the publish workbooks method - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true) - - -``` - - -
-
- - -### ServerInfoItem class - -```py -ServerInfoItem(product_version, build_number, rest_api_version) -``` -The `ServerInfoItem` class contains the build and version information for Tableau Server. The server information is accessed with the `server_info.get()` method, which returns an instance of the `ServerInfo` class. - -**Attributes** - -Name | Description -:--- | :--- -`product_version` | Shows the version of the Tableau Server or Tableau Online (for example, 10.2.0). -`build_number` | Shows the specific build number (for example, 10200.17.0329.1446). -`rest_api_version` | Shows the supported REST API version number. Note that this might be different from the default value specified for the server, with the `Server.version` attribute. To take advantage of new features, you should query the server and set the `Server.version` to match the supported REST API version number. - - -
-
- - -### ServerInfo methods - -The TSC library provides a method to access the build and version information from Tableau Server. - -
- -#### server_info.get - -```py -server_info.get() - -``` -Retrieve the build and version information for the server. - -This method makes an unauthenticated call, so no sign in or authentication token is required. - -REST API: [Server Info](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Server_Info%3FTocPath%3DAPI%2520Reference%7C_____76){:target="_blank"} - -**Parameters** - None - -**Exceptions** - -Error | Description -:--- | :--- -`404003 UNKNOWN_RESOURCE` | Raises an exception if the server info endpoint is not found. - -**Example** - -```py -import tableauserverclient as TSC - -# create a instance of server -server = TSC.Server('http://MY-SERVER') - -# set the version number > 2.3 -# the server_info.get() method works in 2.4 and later -server.version = '2.5' - -s_info = server.server_info.get() -print("\nServer info:") -print("\tProduct version: {0}".format(s_info.product_version)) -print("\tREST API version: {0}".format(s_info.rest_api_version)) -print("\tBuild number: {0}".format(s_info.build_number)) - -``` - - -
-
- - -## Sites - -Using the TSC library, you can query a site or sites on a server, or create or delete a site on the server. - -The site resources for Tableau Server and Tableau Online are defined in the `SiteItem` class. The class corresponds to the site resources you can access using the Tableau Server REST API. The site methods are based upon the endpoints for sites in the REST API and operate on the `SiteItem` class. - -
-
- -### SiteItem class - -```py -SiteItem(name, content_url, admin_mode=None, user_quota=None, storage_quota=None, - disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False) -``` - -The `SiteItem` class contains the members or attributes for the site resources on Tableau Server or Tableau Online. The `SiteItem` class defines the information you can request or query from Tableau Server or Tableau Online. The class members correspond to the attributes of a server request or response payload. - -**Attributes** - -Attribute | Description -:--- | :--- -`name` | The name of the site. The name of the default site is "". -`content_url` | The path to the site. -`admin_mode` | (Optional) For Tableau Server only. Specify `ContentAndUsers` to allow site administrators to use the server interface and **tabcmd** commands to add and remove users. (Specifying this option does not give site administrators permissions to manage users using the REST API.) Specify `ContentOnly` to prevent site administrators from adding or removing users. (Server administrators can always add or remove users.) -`user_quota`| (Optional) Specifies the maximum number of users for the site. If you do not specify this value, the limit depends on the type of licensing configured for the server. For user-based license, the maximum number of users is set by the license. For core-based licensing, there is no limit to the number of users. If you specify a maximum value, only licensed users are counted and server administrators are excluded. -`storage_quota` | (Optional) Specifies the maximum amount of space for the new site, in megabytes. If you set a quota and the site exceeds it, publishers will be prevented from uploading new content until the site is under the limit again. -`disable_subscriptions` | (Optional) Specify `true` to prevent users from being able to subscribe to workbooks on the specified site. The default is `false`. -`subscribe_others_enabled` | (Optional) Specify `false` to prevent server administrators, site administrators, and project or content owners from being able to subscribe other users to workbooks on the specified site. The default is `true`. -`revision_history_enabled` | (Optional) Specify `true` to enable revision history for content resources (workbooks and datasources). The default is `false`. -`revision_limit` | (Optional) Specifies the number of revisions of a content source (workbook or data source) to allow. On Tableau Server, the default is 25. -`state` | Shows the current state of the site (`Active` or `Suspended`). - - -**Example** - -```py - -# create a new instance of a SiteItem - -new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode='ContentAndUsers', user_quota=15, storage_quota=1000, disable_subscriptions=True) - -``` - -Source file: models/site_item.py - -
-
- - -### Site methods - -The TSC library provides methods that operate on sites for Tableau Server and Tableau Online. These methods correspond to endpoints or methods for sites in the Tableau REST API. - - -Source file: server/endpoint/sites_endpoint.py - -
-
- -#### sites.create - -```py -sites.create(site_item) -``` - -Creates a new site on the server for the specified site item object. - -Tableau Server only. - - -REST API: [Create Site](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Create_Site%3FTocPath%3DAPI%2520Reference%7C_____17){:target="_blank"} - - - -**Parameters** - -Name | Description -:--- | :--- -`site_item` | The settings for the site that you want to create. You need to create an instance of `SiteItem` and pass the `create` method. - - -**Returns** - -Returns a new instance of `SiteItem`. - - -**Example** - -```py -import tableauserverclient as TSC - -# create an instance of server -server = TSC.Server('http://MY-SERVER') - -# create shortcut for admin mode -content_users=TSC.SiteItem.AdminMode.ContentAndUsers - -# create a new SiteItem -new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) - -# call the sites create method with the SiteItem -new_site = server.sites.create(new_site) -``` -
-
- -#### sites.get - -```py -sites.get() -``` - -Queries all the sites on the server. - - -REST API: [Query Sites](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Sites%3FTocPath%3DAPI%2520Reference%7C_____58){:target="_blank"} - - -**Parameters** - - None. - -**Returns** - -Returns a list of all `SiteItem` objects and a `PaginationItem`. Use these values to iterate through the results. - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc. - - # query the sites - all_sites, pagination_item = server.sites.get() - - # print all the site names and ids - for site in TSC.Pager(server.sites): - print(site.id, site.name, site.content_url, site.state) - - -``` - -
-
- - - -#### sites.get_by_id - -```py -sites.get_by_id(site_id) -``` - -Queries the site with the given ID. - - -REST API: [Query Site](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Site){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`site_id` | The id for the site you want to query. - - -**Exceptions** - -Error | Description - :--- | : --- -`Site ID undefined.` | Raises an error if an id is not specified. - - -**Returns** - -Returns the `SiteItem`. - - -**Example** - -```py - -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc. - - a_site = server.sites.get_by_id('9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d') - print("\nThe site with id '9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d' is: {0}".format(a_site.name)) - -``` - -
-
- -#### sites.get_by_name - -```py -sites.get_by_name(site_name) -``` - -Queries the site with the specified name. - - -REST API: [Query Site](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Site){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`site_name` | The name of the site you want to query. - - -**Exceptions** - -Error | Description - :--- | : --- -`Site Name undefined.` | Raises an error if an name is not specified. - - -**Returns** - -Returns the `SiteItem`. - - -**Example** - -```py - -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc. - - a_site = server.sites.get_by_name('MY_SITE') - - -``` - -
-
- - - -#### sites.update - -```py -sites.update(site_item) -``` - -Modifies the settings for site. - - -The site item object must include the site ID and overrides all other settings. - - -REST API: [Update Site](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Site%3FTocPath%3DAPI%2520Reference%7C_____84){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`site_item` | The site item that you want to update. The settings specified in the site item override the current site settings. - - -**Exceptions** - -Error | Description -:--- | :--- -`Site item missing ID.` | The site id must be present and must match the id of the site you are updating. -`You cannot set admin_mode to ContentOnly and also set a user quota` | To set the `user_quota`, the `AdminMode` must be set to `ContentAndUsers` - - -**Returns** - -Returns the updated `site_item`. - - -**Example** - -```py -... - -# make some updates to an existing site_item -site_item.name ="New name" - -# call update -site_item = server.sites.update(site_item) - -... -``` - -
-
- - - - -#### sites.delete - - -```py -Sites.delete(site_id) -``` - -Deletes the specified site. - - -REST API: [Delete Site](https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Delete_Site%3FTocPath%3DAPI%2520Reference%7C_____27){:target="_name"} - - -**Parameters** - -Name | Description - :--- | : --- -`site_id` | The id of the site that you want to delete. - - - -**Exceptions** - -Error | Description -:--- | :--- -`Site ID Undefined.` | The site id must be present and must match the id of the site you are deleting. - - - -**Example** - -```py - -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# sign in, etc. - -server.sites.delete('9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d') - -``` - -
-
- - - - -## Sort - -The `Sort` class is used with request options (`RequestOptions`) where you can filter and sort on the results returned from the server. - -You can use the sort and request options to filter and sort the following endpoints: - -- Users -- Datasources -- Workbooks -- Views - -### Sort class - -```py -sort(field, direction) -``` - - - -**Attributes** - -Name | Description -:--- | :--- -`field` | Sets the field to sort on. The fields are defined in the `RequestOption` class. -`direction` | The direction to sort, either ascending (`Asc`) or descending (`Desc`). The options are defined in the `RequestOptions.Direction` class. - -**Example** - -```py - -# create a new instance of a request option object -req_option = TSC.RequestOptions() - -# add the sort expression, sorting by name and direction -req_option.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) -matching_workbooks, pagination_item = server.workbooks.get(req_option) - -for wb in matching_workbooks: - print(wb.name) -``` - -For information about using the `Sort` class, see [Filter and Sort](filter-sort). - -
-
- - - -## Users - -Using the TSC library, you can get information about all the users on a site, and you can add or remove users, or update user information. - -The user resources for Tableau Server are defined in the `UserItem` class. The class corresponds to the user resources you can access using the Tableau Server REST API. The user methods are based upon the endpoints for users in the REST API and operate on the `UserItem` class. - - -### UserItem class - -```py -UserItem(name, site_role, auth_setting=None) -``` - -The `UserItem` class contains the members or attributes for the view resources on Tableau Server. The `UserItem` class defines the information you can request or query from Tableau Server. The class members correspond to the attributes of a server request or response payload. - -**Attributes** - -Name | Description -:--- | :--- -`auth_setting` | (Optional) This attribute is only for Tableau Online. The new authentication type for the user. You can assign the following values for tis attribute: `SAML` (the user signs in using SAML) or `ServerDefault` (the user signs in using the authentication method that's set for the server). These values appear in the **Authentication** tab on the **Settings** page in Tableau Online -- the `SAML` attribute value corresponds to **Single sign-on**, and the `ServerDefault` value corresponds to **TableauID**. -`domain_name` | The name of the site. -`external_auth_user_id` | Represents ID stored in Tableau's single sign-on (SSO) system. The `externalAuthUserId` value is returned for Tableau Online. For other server configurations, this field contains null. -`id` | The id of the user on the site. -`last_login` | The date and time the user last logged in. -`workbooks` | The workbooks the user owns. You must run the populate_workbooks method to add the workbooks to the `UserItem`. -`email` | The email address of the user. -`fullname` | The full name of the user. -`name` | The name of the user. This attribute is required when you are creating a `UserItem` instance. -`site_role` | The role the user has on the site. This attribute is required with you are creating a `UserItem` instance. The `site_role` can be one of the following: `Interactor`, `Publisher`, `ServerAdministrator`, `SiteAdministrator`, `Unlicensed`, `UnlicensedWithPublish`, `Viewer`, `ViewerWithPublish`, `Guest` - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('server') - -# create a new UserItem object. - newU = TSC.UserItem('Monty', 'Publisher') - - print(newU.name, newU.site_role) - -``` - -Source file: models/user_item.py - -
-
- - -### Users methods - -The Tableau Server Client provides several methods for interacting with user resources, or endpoints. These methods correspond to endpoints in the Tableau Server REST API. - -Source file: server/endpoint/users_endpoint.py -
-
- -#### users.add - -```py -users.add(user_item) -``` - -Adds the user to the site. - -To add a new user to the site you need to first create a new `user_item` (from `UserItem` class). When you create a new user, you specify the name of the user and their site role. For Tableau Online, you also specify the `auth_setting` attribute in your request. When you add user to Tableau Online, the name of the user must be the email address that is used to sign in to Tableau Online. After you add a user, Tableau Online sends the user an email invitation. The user can click the link in the invitation to sign in and update their full name and password. - -REST API: [Add User to Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Add_User_to_Site%3FTocPath%3DAPI%2520Reference%7C_____9){:target="_blank"} - -**Parameters** - -Name | Description - :--- | : --- -`user_item` | You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific user, you could specify the name of the user or the user's id. - - -**Returns** - -Returns the new `UserItem` object. - - - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('server') -# login, etc. - -# create a new UserItem object. - newU = TSC.UserItem('Heather', 'Publisher') - -# add the new user to the site - newU = server.users.add(newU) - print(newU.name, newU.site_role) - -``` - -#### users.get - -```py -users.get(req_options=None) -``` - -Returns information about the users on the specified site. - -To get information about the workbooks a user owns or has view permission for, you must first populate the `UserItem` with workbook information using the [populate_workbooks(*user_item*)](#populate-workbooks-user) method. - - -REST API: [Get Users on Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Get_Users_on_Site%3FTocPath%3DAPI%2520Reference%7C_____41){:target="_blank"} - -**Parameters** - -Name | Description - :--- | : --- -`req_option` | (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific user, you could specify the name of the user or the user's id. - - -**Returns** - -Returns a list of `UserItem` objects and a `PaginationItem` object. Use these values to iterate through the results. - - -**Example** - - -```py -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVERURL') - -with server.auth.sign_in(tableau_auth): - all_users, pagination_item = server.users.get() - print("\nThere are {} user on site: ".format(pagination_item.total_available)) - print([user.name for user in all_users]) -```` - -
-
- -#### users.get_by_id - -```py -users.get_by_id(user_id) -``` - -Returns information about the specified user. - -REST API: [Query User On Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_User_On_Site%3FTocPath%3DAPI%2520Reference%7C_____61){:target="_blank"} - - -**Parameters** - -Name | Description - :--- | : --- -`user_id` | The `user_id` specifies the user to query. - - -**Exceptions** - -Error | Description - :--- | : --- -`User ID undefined.` | Raises an exception if a valid `user_id` is not provided. - - -**Returns** - -The `UserItem`. See [UserItem class](#useritem-class) - - -**Example** - -```py - user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - print(user1.name) - -``` - -
-
- - -#### users.populate_favorites - -```py -users.populate_favorites(user_item) -``` - -Returns the list of favorites (views, workbooks, and data sources) for a user. - -*Not currently implemented* - -
-
- - -#### users.populate_workbooks - -```py -users.populate_workbooks(user_item, req_options=None): -``` - -Returns information about the workbooks that the specified user owns and has Read (view) permissions for. - - -This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for explicitly. When you query for all the users, the workbook information for each user is not included. Use this method to retrieve information about the workbooks that the user owns or has Read (view) permissions. The method adds the list of workbooks to the user item object (`user_item.workbooks`). - -REST API: [Query Datasource Connections](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Datasource_Connections%3FTocPath%3DAPI%2520Reference%7C_____47){:target="_blank"} - -**Parameters** - -Name | Description - :--- | : --- -`user_item` | The `user_item` specifies the user to populate with workbook information. - - - - -**Exceptions** - -Error | Description - :--- | : --- -`User item missing ID.` | Raises an error if the `user_item` is unspecified. - - -**Returns** - -A list of `WorkbookItem` - -A `PaginationItem` that points (`user_item.workbooks`). See [UserItem class](#useritem-class) - - -**Example** - -```py -# first get all users, call server.users.get() -# get workbooks for user[0] - ... - - page_n = server.users.populate_workbooks(all_users[0]) - print("\nUser {0} owns or has READ permissions for {1} workbooks".format(all_users[0].name, page_n.total_available)) - print("\nThe workbooks are:") - for workbook in all_users[0].workbooks : - print(workbook.name) - - ... -``` - - - - -
-
- -#### users.remove - -```py -users.remove(user_id) -``` - - - -Removes the specified user from the site. - -REST API: [Remove User from Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Remove_User_from_Site%3FTocPath%3DAPI%2520Reference%7C_____74){:target="_blank"} - - -**Parameters** - -Name | Description - :--- | : --- -`user_id` | The identifier (`id`) for the user that you want to remove from the server. - - -**Exceptions** - -Error | Description - :--- | : --- -`User ID undefined` | Raises an exception if a valid `user_id` is not provided. - - -**Example** - -```py -# Remove a user from the site - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - -``` -
-
- - - - -#### users.update - -```py -users.update(user_item, password=None) -``` - -Updates information about the specified user. - -The information you can modify depends upon whether you are using Tableau Server or Tableau Online, and whether you have configured Tableau Server to use local authentication or Active Directory. For more information, see [Update User](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_User%3FTocPath%3DAPI%2520Reference%7C_____86){:target="_blank"}. - - - -REST API: [Update User](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_User%3FTocPath%3DAPI%2520Reference%7C_____86){:target="_blank"} - -**Parameters** - -Name | Description - :--- | : --- -`user_item` | The `user_item` specifies the user to update. -`password` | (Optional) The new password for the user. - - - -**Exceptions** - -Error | Description - :--- | : --- -`User item missing ID.` | Raises an error if the `user_item` is unspecified. - - -**Returns** - -An updated `UserItem`. See [UserItem class](#useritem-class) - - -**Example** - -```py - -# import tableauserverclient as TSC -# tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -# server = TSC.Server('http://SERVERURL') - - with server.auth.sign_in(tableau_auth): - - # create a new user_item - user1 = TSC.UserItem('temp', 'Viewer') - - # add new user - user1 = server.users.add(user1) - print(user1.name, user1.site_role, user1.id) - - # modify user info - user1.name = 'Laura' - user1.fullname = 'Laura Rodriguez' - user1.email = 'laura@example.com' - - # update user - user1 = server.users.update(user1) - print("\Updated user info:") - print(user1.name, user1.fullname, user1.email, user1.id) - - -``` - - - -
-
- - - - -## Views - -Using the TSC library, you can get all the views on a site, or get the views for a workbook, or populate a view with preview images. -The view resources for Tableau Server are defined in the `ViewItem` class. The class corresponds to the view resources you can access using the Tableau Server REST API, for example, you can find the name of the view, its id, and the id of the workbook it is associated with. The view methods are based upon the endpoints for views in the REST API and operate on the `ViewItem` class. - - -
- -### ViewItem class - -``` -class ViewItem(object) - -``` - -The `ViewItem` class contains the members or attributes for the view resources on Tableau Server. The `ViewItem` class defines the information you can request or query from Tableau Server. The class members correspond to the attributes of a server request or response payload. - -Source file: models/view_item.py - -**Attributes** - -Name | Description -:--- | :--- -`id` | The identifier of the view item. -`name` | The name of the view. -`owner_id` | The id for the owner of the view. -`preview_image` | The thumbnail image for the view. -`total_views` | The usage statistics for the view. Indicates the total number of times the view has been looked at. -`workbook_id` | The id of the workbook associated with the view. - - -
-
- - -### Views methods - -The Tableau Server Client provides two methods for interacting with view resources, or endpoints. These methods correspond to the endpoints for views in the Tableau Server REST API. - -Source file: server/endpoint/views_endpoint.py - -
-
- -#### views.get -``` -views.get(req_option=None) -``` - -Returns the list of views items for a site. - - -REST API: [Query Views for Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Views_for_Site%3FTocPath%3DAPI%2520Reference%7C_____64){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`req_option` | (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific view, you could specify the name of the view or its id. - - - -**Returns** - -Returns a list of all `ViewItem` objects and a `PaginationItem`. Use these values to iterate through the results. - -**Example** - -```py -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('username', 'password') -server = TSC.Server('http://servername') - -with server.auth.sign_in(tableau_auth): - all_views, pagination_item = server.views.get() - print([view.name for view in all_views]) - -```` - -See [ViewItem class](#viewitem-class) - - -
-
- -#### views.populate_preview_image - -```py - views.populate_preview_image(view_item) - -``` - -Populates a preview image for the specified view. - -This method gets the preview image (thumbnail) for the specified view item. The method uses the `view.id` and `workbook.id` to identify the preview image. The method populates the `view.preview_image` for the view. - -REST API: [Query View Preview Image](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Workbook_Preview_Image%3FTocPath%3DAPI%2520Reference%7C_____69){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`view_item` | The view item specifies the `view.id` and `workbook.id` that identifies the preview image. - - -**Exceptions** - -Error | Description -:--- | :--- -`View item missing ID or workbook ID` | Raises an error if the ID for the view item or workbook is missing. - - - -**Returns** - -None. The preview image is added to the view. - -See [ViewItem class](#viewitem-class) - -
-
- - - - - -## Workbooks - -Using the TSC library, you can get information about a specific workbook or all the workbooks on a site, and you can publish, update, or delete workbooks. - -The project resources for Tableau are defined in the `WorkbookItem` class. The class corresponds to the workbook resources you can access using the Tableau REST API. The workbook methods are based upon the endpoints for projects in the REST API and operate on the `WorkbookItem` class. - - - - - -
-
- -### WorkbookItem class - -```py - - WorkbookItem(project_id, name=None, show_tabs=False) - -``` -The workbook resources for Tableau are defined in the `WorkbookItem` class. The class corresponds to the workbook resources you can access using the Tableau REST API. Some workbook methods take an instance of the `WorkbookItem` class as arguments. The workbook item specifies the project - - -**Attributes** - -Name | Description -:--- | :--- -`connections` | The list of data connections (`ConnectionItem`) for the data sources used by the workbook. You must first call the [workbooks.populate_connections](#workbooks.populate_connections) method to access this data. See the [ConnectionItem class](#connectionitem-class). -`content_url` | The name of the data source as it would appear in a URL. -`created_at` | The date and time when the data source was created. -`id` | The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the `get_by_id` and `delete` methods. -`name` | The name of the workbook. -`owner_id` | The ID of the owner. -`preview_image` | The thumbnail image for the view. You must first call the [workbooks.populate_preview_image](#workbooks.populate_preview_image) method to access this data. -`project_id` | The project id. -`project_name` | The name of the project. -`size` | The size of the workbook (in megabytes). -`show_tabs` | (Boolean) Determines whether the workbook shows tabs for the view. -`tags` | The tags that have been added to the workbook. This is a list of strings, e.g ["tag"]. -`updated_at` | The date and time when the workbook was last updated. -`views` | The list of views (`ViewItem`) for the workbook. You must first call the [workbooks.populate_views](#workbooks.populate_views) method to access this data. See the [ViewItem class](#viewitem-class). - - - - - -**Example** - -```py -# creating a new instance of a WorkbookItem -# -import tableauserverclient as TSC - -# Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' - - new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') - - -```` - -Source file: models/workbook_item.py - -
-
- -### Workbook methods - -The Tableau Server Client (TSC) library provides methods for interacting with workbooks. These methods correspond to endpoints in the Tableau Server REST API. For example, you can use the library to publish, update, download, or delete workbooks on the site. -The methods operate on a workbook object (`WorkbookItem`) that represents the workbook resources. - - - -Source files: server/endpoint/workbooks_endpoint.py - -
-
- -#### workbooks.get - -```py -workbooks.get(req_options=None) -``` - -Queries the server and returns information about the workbooks the site. - - - - - -REST API: [Query Workbooks for Site](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Workbooks_for_Site%3FTocPath%3DAPI%2520Reference%7C_____70){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`req_option` | (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific workbook, you could specify the name of the workbook or the name of the owner. See [Filter and Sort](filter-sort) - - -**Returns** - -Returns a list of all `WorkbookItem` objects and a `PaginationItem`. Use these values to iterate through the results. - - -**Example** - -```py - -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('username', 'password', site_id='site') -server = TSC.Server('http://servername') - -with server.auth.sign_in(tableau_auth): - all_workbook_items, pagination_item = server.workbooks.get() - print([workbook.name for workbook in all_workbook_items]) - - - -``` - -
-
- - - -#### workbooks.get_by_id - -```py -workbooks.get_by_id(workbook_id) -``` - -Returns information about the specified workbook on the site. - -REST API: [Query Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Workbook%3FTocPath%3DAPI%2520Reference%7C_____66){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`workbook_id` | The `workbook_id` specifies the workbook to query. The ID is a LUID (64-bit hexadecimal string). - - -**Exceptions** - -Error | Description - :--- | : --- -`Workbook ID undefined` | Raises an exception if a `workbook_id` is not provided. - - -**Returns** - -The `WorkbookItem`. See [WorkbookItem class](#workbookitem-class) - - -**Example** - -```py - -workbook = server.workbooks.get_by_id('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') -print(workbook.name) - -``` - - -
-
- - -#### workbooks.publish - -```py -workbooks.publish(workbook_item, file_path, publish_mode) -``` - -Publish a workbook to the specified site. - -**Note:** The REST API cannot automatically include -extracts or other resources that the workbook uses. Therefore, - a .twb file that uses data from an Excel or csv file on a local computer cannot be published, -unless you package the data and workbook in a .twbx file, or publish the data source separately. - -For workbooks that are larger than 64 MB, the publish method automatically takes care of chunking the file in parts for uploading. Using this method is considerably more convenient than calling the publish REST APIs directly. - -REST API: [Publish Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Publish_Workbook%3FTocPath%3DAPI%2520Reference%7C_____45){:target="_blank"}, [Initiate File Upload](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Initiate_File_Upload%3FTocPath%3DAPI%2520Reference%7C_____43){:target="_blank"}, [Append to File Upload](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Append_to_File_Upload%3FTocPath%3DAPI%2520Reference%7C_____13){:target="_blank"} - - - -**Parameters** - -Name | Description -:--- | :--- -`workbook_item` | The `workbook_item` specifies the workbook you are publishing. When you are adding a workbook, you need to first create a new instance of a `workbook_item` that includes a `project_id` of an existing project. The name of the workbook will be the name of the file, unless you also specify a name for the new workbook when you create the instance. See [WorkbookItem](#workbookitem-class). -`file_path` | The path and name of the workbook to publish. -`mode` | Specifies whether you are publishing a new workbook (`CreateNew`) or overwriting an existing workbook (`Overwrite`). You cannot appending workbooks. You can also use the publish mode attributes, for example: `TSC.Server.PublishMode.Overwrite`. -`connection_credentials` | (Optional) The credentials (if required) to connect to the workbook's data source. The `ConnectionCredentials` object contains the authentication information for the data source (user name and password, and whether the credentials are embeded or OAuth is used). - - - -**Exceptions** - -Error | Description -:--- | :--- -`File path does not lead to an existing file.` | Raises an error of the file path is incorrect or if the file is missing. -`Invalid mode defined.` | Raises an error if the publish mode is not one of the defined options. -`Workbooks cannot be appended.` | The `mode` must be set to `Overwrite` or `CreateNew`. -`Only .twb or twbx files can be published as workbooks.` | Raises an error if the type of file specified is not supported. - -See the REST API [Publish Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Publish_Workbook%3FTocPath%3DAPI%2520Reference%7C_____45){:target="_blank"} for additional error codes. - -**Returns** - -The `WorkbookItem` for the workbook that was published. - - -**Example** - -```py - -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('username', 'password', site_id='site') -server = TSC.Server('http://servername') - -with server.auth.sign_in(tableau_auth): - # create a workbook item - wb_item = TSC.WorkbookItem(name='Sample', project_id='1f2f3e4e-5d6d-7c8c-9b0b-1a2a3f4f5e6e') - # call the publish method with the workbook item - wb_item = server.workbooks.publish(wb_item, 'SampleWB.twbx', 'Overwrite') -``` - -
-
- - -#### workbooks.update - -```py -workbooks.update(workbook_item) -``` - - -Modifies an existing workbook. Use this method to change the owner or the project that the workbook belongs to, or to change whether the workbook shows views in tabs. The workbook item must include the workbook ID and overrides the existing settings. - -REST API: [Update Workbooks](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Workbook%3FTocPath%3DAPI%2520Reference%7C_____87){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`workbook_item` | The `workbook_item` specifies the settings for the workbook you are updating. You can change the `owner_id`, `project_id`, and the `show_tabs` values. See [WorkbookItem](#workbookitem-class). - - -**Exceptions** - -Error | Description -:--- | :--- -`Workbook item missing ID. Workbook must be retrieved from server first.` | Raises an error if the `workbook_item` is unspecified. Use the `workbooks.get()` or `workbooks.get_by_id()` methods to retrieve the workbook item from the server. - - -```py - -import tableauserverclient as TSC -tableau_auth = TSC.TableauAuth('username', 'password', site_id='site') -server = TSC.Server('http://servername') - -with server.auth.sign_in(tableau_auth): - - # get the workbook item from the site - workbook = server.workbooks.get_by_id('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - print("\nUpdate {0} workbook. Project was {1}".format(workbook.name, workbook.project_name)) - - - # make an change, for example a new project ID - workbook.project_id = '1f2f3e4e-5d6d-7c8c-9b0b-1a2a3f4f5e6e' - - # call the update method - workbook = server.workbooks.update(workbook) - print("\nUpdated {0} workbook. Project is now {1}".format(workbook.name, workbook.project_name)) - - -``` - - -
-
- - - -#### workbooks.delete - -```py -workbooks.delete(workbook_id) -``` - -Deletes a workbook with the specified ID. - - - -To specify the site, create a `TableauAuth` instance using the content URL for the site (`site_id`), and sign in to that site. See the [TableauAuth class](#tableauauth-class). - - -REST API: [Delete Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Delete_Workbook%3FTocPath%3DAPI%2520Reference%7C_____31){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`workbook_id` | The ID of the workbook to delete. - - - - -**Exceptions** - -Error | Description -:--- | :--- -`Workbook ID undefined.` | Raises an exception if the project item does not have an ID. The project ID is sent to the server as part of the URI. - - -**Example** - -```py -# import tableauserverclient as TSC -# server = TSC.Server('http://MY-SERVER') -# tableau_auth sign in, etc. - - server.workbooks.delete('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - -``` - - -
-
- - -#### workbooks.download - -```py -workbooks.download(workbook_id, filepath=None, no_extract=False) -``` - -Downloads a workbook to the specified directory (optional). - - -REST API: [Download Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Download_Workbook%3FTocPath%3DAPI%2520Reference%7C_____36){:target="_blank"} - - -**Parameters** - -Name | Description -:--- | :--- -`workbook_id` | The ID for the `WorkbookItem` that you want to download from the server. -`filepath` | (Optional) Downloads the file to the location you specify. If no location is specified, the file is downloaded to the current working directory. The default is `Filepath=None`. -`no_extract` | (Optional) Specifies whether to download the file without the extract. When the workbook has an extract, if you set the parameter `no_extract=True`, the extract is not included. You can use this parameter to improve performance if you are downloading workbooks that have large extracts. The default is to include the extract, if present (`no_extract=False`). Available starting with Tableau Server REST API version 2.5. - - - -**Exceptions** - -Error | Description -:--- | :--- -`Workbook ID undefined` | Raises an exception if a valid `datasource_id` is not provided. - - -**Returns** - -The file path to the downloaded workbook. - - -**Example** - -```py - - file_path = server.workbooks.download('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - print("\nDownloaded the file to {0}.".format(file_path)) - -```` - - -
-
- - -#### workbooks.populate_views - -```py -workbooks.populate_views(workbook_item) -``` - -Populates (or gets) a list of views for a workbook. - -You must first call this method to populate views before you can iterate through the views. - -This method retrieves the view information for the specified workbook. The REST API is designed to return only the information you ask for explicitly. When you query for all the data sources, the view information is not included. Use this method to retrieve the views. The method adds the list of views to the workbook item (`workbook_item.views`). This is a list of `ViewItem`. - -REST API: [Query Views for Workbook](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Views_for_Workbook%3FTocPath%3DAPI%2520Reference%7C_____65){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`workbook_item` | The `workbook_item` specifies the workbook to populate with views information. See [WorkbookItem class](#workbookitem-class). - - - - -**Exceptions** - -Error | Description -:--- | :--- -`Workbook item missing ID. Workbook must be retrieved from server first.` | Raises an error if the `workbook_item` is unspecified. You can retrieve the workbook items using the `workbooks.get()` and `workbooks.get_by_id()` methods. - - -**Returns** - -None. A list of `ViewItem` objects are added to the workbook (`workbook_item.views`). - - -**Example** - -```py -# import tableauserverclient as TSC - -# server = TSC.Server('http://SERVERURL') -# - ... - -# get the workbook item - workbook = server.workbooks.get_by_id('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - - -# get the view information - server.workbooks.populate_views(workbook) - -# print information about the views for the work item - print("\nThe views for {0}: ".format(workbook.name)) - print([view.name for view in workbook.views]) - - ... - -``` - -
-
- -#### workbooks.populate_connections - -```py -workbooks.populate_connections(workbook_item) -``` - -Populates a list of data source connections for the specified workbook. - -You must populate connections before you can iterate through the -connections. - -This method retrieves the data source connection information for the specified workbook. The REST API is designed to return only the information you ask for explicitly. When you query all the workbooks, the data source connection information is not included. Use this method to retrieve the connection information for any data sources used by the workbook. The method adds the list of data connections to the workbook item (`workbook_item.connections`). This is a list of `ConnectionItem`. - -REST API: [Query Workbook Connections](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Workbook_Connections%3FTocPath%3DAPI%2520Reference%7C_____67){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`workbook_item` | The `workbook_item` specifies the workbook to populate with data connection information. - - - - -**Exceptions** - -Error | Description -:--- | :--- -`Workbook item missing ID. Workbook must be retrieved from server first.` | Raises an error if the `workbook_item` is unspecified. - - -**Returns** - -None. A list of `ConnectionItem` objects are added to the data source (`workbook_item.connections`). - - -**Example** - -```py -# import tableauserverclient as TSC - -# server = TSC.Server('http://SERVERURL') -# - ... - -# get the workbook item - workbook = server.workbooks.get_by_id('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - - -# get the connection information - server.workbooks.populate_connections(workbook) - -# print information about the data connections for the workbook item - print("\nThe connections for {0}: ".format(workbook.name)) - print([connection.id for connection in workbook.connections]) - - - ... - -``` - -
-
- - -#### workbooks.populate_preview_image - -```py -workbooks.populate_preview_image(workbook_item) -``` - -This method gets the preview image (thumbnail) for the specified workbook item. - -The method uses the `view.id` and `workbook.id` to identify the preview image. The method populates the `workbook_item.preview_image`. - -REST API: [Query View Preview Image](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Query_Workbook_Preview_Image%3FTocPath%3DAPI%2520Reference%7C_____69){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`view_item` | The view item specifies the `view.id` and `workbook.id` that identifies the preview image. - - - -**Exceptions** - -Error | Description -:--- | :--- -`View item missing ID or workbook ID` | Raises an error if the ID for the view item or workbook is missing. - - - -**Returns** - -None. The preview image is added to the view. - - - -**Example** - -```py - -# import tableauserverclient as TSC - -# server = TSC.Server('http://SERVERURL') - - ... - - # get the workbook item - workbook = server.workbooks.get_by_id('1a1b1c1d-2e2f-2a2b-3c3d-3e3f4a4b4c4d') - - # add the png thumbnail to the workbook item - server.workbooks.populate_preview_image(workbook) - - -``` - -#### workbooks.update_connection - -```py -workbooks.update_conn(workbook_item, connection_item) -``` - -Updates a workbook connection information (server address, server port, user name, and password). - -The workbook connections must be populated before the strings can be updated. See [workbooks.populate_connections](#workbooks.populate_connections) - -REST API: [Update Workbook Connection](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Update_Workbook_Connection%3FTocPath%3DAPI%2520Reference%7C_____88){:target="_blank"} - -**Parameters** - -Name | Description -:--- | :--- -`workbook_item` | The `workbook_item` specifies the workbook to populate with data connection information. -`connection_item` | The `connection_item` that has the information you want to update. - - - -**Returns** - -None. The connection information is updated with the information in the `ConnectionItem`. - - - - -**Example** - -```py - -# update connection item user name and password -workbook.connections[0].username = 'USERNAME' -workbook.connections[0].password = 'PASSWORD' - -# call the update method -server.workbooks.update_conn(workbook, workbook.connections[0]) -``` - -
-
- - diff --git a/docs/docs/dev-guide.md b/docs/docs/dev-guide.md deleted file mode 100644 index 1d85da5a4..000000000 --- a/docs/docs/dev-guide.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: Developer Guide -layout: docs ---- - -This topic describes how to contribute to the Tableau Server Client (Python) project. - -* TOC -{:toc} - -## Submit your first patch - -1. Make sure you have [signed the CLA](http://tableau.github.io/#contributor-license-agreement-cla) - -1. Fork the repository. - - We follow the "Fork and Pull" model as described [here](https://help.github.com/articles/about-collaborative-development-models/). - -1. Clone your fork: - - ```shell - git clone git@github.com:/server-client-python.git - ``` - -1. Switch to the development branch - - ```shell - git checkout development - ``` - -1. Run the tests to make sure everything is peachy: - - ```shell - python setup.py test - ``` - -1. Set up the feature, fix, or documentation branch. - - It is recommended to use the format issue#-type-description (e.g. 13-fix-connection-bug) like so: - - ```shell - git checkout -b 13-feature-new-stuff - ``` - -1. Code and commit! - - Here's a quick checklist for ensuring a good pull request: - - - Only touch the minimal amount of files possible while still accomplishing the goal. - - Ensure all indentation is done as 4-spaces and your editor is set to unix line endings. - - The code matches PEP8 style guides. If you cloned the repo you can run `pycodestyle .` - - Keep commit messages clean and descriptive. - If the PR is accepted it will get 'Squashed' into a single commit before merging, the commit messages will be used to generate the Merge commit message. - -1. Add tests. - - All of our tests live under the `test/` folder in the repository. - We use `unittest` and the built-in test runner `python setup.py test`. - If a test needs a static file, like a twb/twbx, it should live under `test/assets/` - -1. Update the documentation. - - Our documentation is written in markdown and built with Jekyll on Github Pages. All of the documentation source files can be found in `docs/docs`. - - When adding a new feature or improving existing functionality we may ask that you update the documentation along with your code. - - If you are just making a PR for documentation updates (adding new docs, fixing typos, improving wording) the easiest method is to use the built in `Edit this file` in the Github UI - -1. Submit to your fork. - -1. Make a PR as described [here](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) against the 'development' branch. - -1. Wait for a review and address any feedback. - While we try and stay on top of all issues and PRs it might take a few days for someone to respond. Politely pinging - the PR after a few days with no response is OK, we'll try and respond with a timeline as soon as we are able. - -1. That's it! When the PR has received :rocket:'s from members of the core team they will merge the PR - - -## Add new features - -1. Create an endpoint class for the new feature, following the structure of the other endpoints. Each endpoint usually - has `get`, `post`, `update`, and `delete` operations that require making the url, creating the XML request if necesssary, - sending the request, and creating the target item object based on the server response. - -1. Create an item class for the new feature, following the structure of the other item classes. Each item has properties - that correspond to what attributes are sent to/received from the server (refer to docs and Postman for attributes). - Some items also require constants for user input that are limited to specific strings. After making all the - properties, make the parsing method that takes the server response and creates an instances of the target item. If - the corresponding endpoint class has an update function, then parsing is broken into multiple parts (refer to another - item like workbook or datasource for example). - -1. Add testing by getting real xml responses from the server, and asserting that all properties are parsed and set - correctly. - -1. Add a sample to show users how to use the new feature. - - diff --git a/docs/docs/filter-sort.md b/docs/docs/filter-sort.md deleted file mode 100644 index f63e32f19..000000000 --- a/docs/docs/filter-sort.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Filter and Sort -layout: docs ---- -Use the `RequestOptions` object to define filtering and sorting criteria for an endpoint, -then pass the object to your endpoint as a parameter. - -* TOC -{:toc} - - -## Available endpoints and fields - -You can use the TSC library to filter and sort the following endpoints: - -* Users -* Datasources -* Workbooks -* Views - -For the above endpoints, you can filter or sort on the following -fields: - -* CreatedAt -* LastLogin -* Name -* OwnerName -* SiteRole -* Tags -* UpdatedAt - -**Important**: Not all of the fields are available for all endpoints. For more information, see the [REST -API help](http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_concepts_filtering_and_sorting.htm). - -## Filtering - -To filter on a field, you need to specify the following criteria: - -### Operator criteria - -The operator that you want to use for that field. For example, you can use the Equals operator to get everything from the endpoint that matches exactly. - -The operator can be any of the following: - -* Equals -* GreaterThan -* GreaterThanOrEqual -* LessThan -* In - -### Value criteria - -The value that you want to filter on. This can be any valid string. - -### Filtering example - -The following code displays only the workbooks where the name equals Superstore: - -```py -req_option = TSC.RequestOptions() -req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - 'Superstore')) -matching_workbooks, pagination_item = server.workbooks.get(req_option) - -print(matching_workbooks[0].owner_id) -``` - -## Sorting - -To sort on a field, you need to specify the direction in which you want to sort. - -### Direction criteria - -This can be either `Asc` for ascending or `Desc` for descending. - -### Sorting example - -The following code sorts the workbooks in ascending order: - -```py -req_option = TSC.RequestOptions() -req_option.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) -matching_workbooks, pagination_item = server.workbooks.get(req_option) - -for wb in matching_workbooks: - print(wb.name) -``` - diff --git a/docs/docs/index.md b/docs/docs/index.md deleted file mode 100644 index 9fd8b699e..000000000 --- a/docs/docs/index.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Get Started -layout: docs ---- - -Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With -the TSC library you can do almost everything that you can do with the REST API, including: - -* Publish workbooks and data sources. -* Create users and groups. -* Query projects, sites, and more. - -This section describes how to: - -* TOC -{:toc} - -## Confirm prerequisites - -Before you install TSC, confirm that you have the following dependencies installed: - -* Python. You can use TSC with Python 2.7.9 or later and with Python 3.3 or later. These versions include pip, which is - the recommended means of installing TSC. -* Git. Optional, but recommended to download the samples or install from the source code. - - -## Install TSC - -You can install TSC with pip or from the source code. - -### Install with pip (recommended) - -Run the following command to install the latest stable version of TSC: - -``` -pip install tableauserverclient -``` - -### Install from the development branch - -You can install from the development branch for a preview of upcoming features. Run the following command -to install from the development branch: - -``` -pip install git+https://github.com/tableau/server-client-python.git@development -``` - -Note that the version from the development branch should not be used for production code. The methods and endpoints in the -development version are subject to change at any time before the next stable release. - -## Get the samples - -The TSC samples are included in the `samples` directory of the TSC repository on Github. You can run the following command to clone the -repository: - -``` -git clone git@github.com:tableau/server-client-python.git -``` - -For more information on the samples and how to run the samples, see [Samples]({{ site.baseurl }}/docs/samples). - -## Write your first program - -Run the following code to get a list of all the data sources on your installation of Tableau Server: - -```py -import tableauserverclient as TSC - -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVER_URL') - -with server.auth.sign_in(tableau_auth): - all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) - print([datasource.name for datasource in all_datasources]) -``` diff --git a/docs/docs/page-through-results.md b/docs/docs/page-through-results.md deleted file mode 100644 index b01a1455b..000000000 --- a/docs/docs/page-through-results.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Page through Results -layout: docs ---- - -Many of the calls that you make with the TSC library query for resources (like workbooks or data sources) on Tableau -Server. Because the number of resources on Tableau Server can be very large, Tableau Server only returns the first 100 -resources by default. To get all of the resources on Tableau Server, you need to page through the results. - -* TOC -{:toc} - -## The Pager generator - -The simplest way to page through results is to use the `Pager` generator on any endpoint with a `get` function. - -For example, to get all of the workbooks on Tableau Server, run the following code: - -```py -for wb in TSC.Pager(server.workbooks): - print(wb.name) -``` - -The `Pager` generator function returns one resource for each time that it is called. To get all the resources on the -server, you can make multiple calls to the `Pager` function. For example, you can use a `for ... in` loop to call the -`Pager` function until there are no resources remaining. Note that the `Pager` generator only makes calls to the Tableau -Server REST API when it runs out of resources--it does not make a call for each resource. - -**Tip**: For more information on generators, see the [Python wiki](https://wiki.python.org/moin/Generators). - -### Set pagination options - -You can set pagination options in the request options and then pass the request options to the `Pager` function as a -second optional parameter. - -For example, to set the page size to 1000 use the following code: - -```py -request_options = TSC.RequestOptions(pagesize=1000) -all_workbooks = list(TSC.Pager(server.workbooks, request_options)) -``` - -You can also set the page number where you want to start like so: - -```py -request_options = TSC.RequestOptions(pagenumber=5) -all_workbooks = list(TSC.Pager(server.workbooks, request_options)) -``` - -### Use list comprehensions and generator expressions - -The `Pager` generator can also be used in list comprehensions or generator expressions for compactness and easy -filtering. Generator expressions will use less memory than list comprehensions. The following example shows how to use -the `Pager` generator with list comprehensions and generator expressions: - -```py -# List comprehension -[wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')] - -# Generator expression -(wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')) -``` - -If you want to load all the resources returned by the `Pager` generator in memory (rather than one at a time), then you -can insert the elements into a list: - -```py -all_workbooks = list(TSC.Pager(server.workbooks)) -``` diff --git a/docs/docs/populate-connections-views.md b/docs/docs/populate-connections-views.md deleted file mode 100644 index 52dc3826f..000000000 --- a/docs/docs/populate-connections-views.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Populate Connections and Views -layout: docs ---- - -When you get a workbook with the TSC library, the response from Tableau Server does not include information about the -views or connections that make up the workbook. Similarly, when you get a data source, the response does not include -information about the connections that make up the data source. This is a result of the design of the Tableau Server -REST API, which optimizes the size of responses by only returning what you ask for explicitly. - -As a result, if you want to get views and connections, you need to run the `populate_views` and `populate_connections` -functions. - -* TOC -{:toc} - -## Populate views for workbooks - -```py -workbook = server.workbooks.get_by_id('a1b2c3d4') -print(workbook.id) - -server.workbooks.populate_views(workbook) -print([view.name for view in workbook.views]) -``` - -## Populate connections for workbooks - -```py -workbook = server.workbooks.get_by_id('a1b2c3d4') -print(workbook.id) - -server.workbooks.populate_connections(workbook) -print([connection.datasource_name for connection in workbook.connections]) -``` - -## Update connections for workbooks - -```py -server.workbooks.populate_connections(workbook) -conn_to_update = workbook.connections[0] -conn_to_update.server_address = 'new_address' -conn_to_update.server_port = 1234 -conn_to_update.username = 'username' -conn_to_update.password = 'password' -conn_to_update.embed_password = True/False -server.workbooks.update_conn(workbook, conn_to_update) -``` - -## Populate connections for data sources - -```py -datasource = server.datasources.get_by_id('a1b2c3d4') -print(datasource.name) - -server.datasources.populate_connections(datasource) -print([connection.datasource_name for connection in datasource.connections]) -``` diff --git a/docs/docs/readme.md b/docs/docs/readme.md new file mode 100644 index 000000000..03e495ee5 --- /dev/null +++ b/docs/docs/readme.md @@ -0,0 +1,3 @@ +To view the documentation source for the Tableau Server CLient library, find the `doc` folder in the `gh-pages` branch of this repo. + +To contribute, see the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide#update-the-documentation) page. \ No newline at end of file diff --git a/docs/docs/samples.md b/docs/docs/samples.md deleted file mode 100644 index 875744433..000000000 --- a/docs/docs/samples.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -title: Samples -layout: docs ---- - -The TSC samples are included in the `samples` directory of the TSC repository [on Github](https://github.com/tableau/server-client-python). - -* TOC -{:toc} - -## Run the samples - -Each of the samples requires the following arguments: - -* `--server`. The URL for the Tableau Server that you want to connect to. -* `--username`. The user name of the Tableau Server account that you want to use. When you run the samples, you are - prompted for a password for the user account that you enter. - -Additionally, some of the samples require that you enter other arguments when you run them. For more information about -the arguments required by a particular sample, run the sample with the `-h` flag to see the help output. - -For example, if you run the following command: - -``` -python samples/publish_workbook.py -h -``` - -You might see that you need to enter a server address, a user name, and a file path for the workbook that you want to -publish. - -## Samples list - -The following list describes the samples available in the repository: - -* `create_group.py`. Create a user group. - -* `create_project.py`. Create new projects at the top level as well as nested projects. - -* `create_schedules.py`. Create schedules for extract refreshes and subscriptions. - -* `explore_datasource.py`. Queries datasources, selects a datasource, populates connections for the datasource, then updates the datasource. - -* `explore_workbook.py`. Queries workbooks, selects a workbook, populates the connections and views for a workbook, then updates the workbook. - -* `download_view_image.py`. Queries for view based on name specified in filter, populates the image and saves the image to specified file path. - -* `move_workbook_projects.py`. Updates the properties of a workbook to move the workbook from one project to another. - -* `move_workbook_sites.py`. Downloads a workbook, stores it in-memory, and uploads it to another site. - -* `pagination_sample.py`. Use the Pager generator to iterate over all the items on the server. - -* `publish_workbook.py`. Publishes a Tableau workbook. - -* `set_http_options.py`. Sets HTTP options for the server and specifically for downloading workbooks. - -**Note**: For all of the samples, ensure that your Tableau Server user account has permission to access the resources -requested by the samples. diff --git a/docs/docs/search.md b/docs/docs/search.md deleted file mode 100644 index c35daec37..000000000 --- a/docs/docs/search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Search -layout: search ---- - diff --git a/docs/docs/sign-in-out.md b/docs/docs/sign-in-out.md deleted file mode 100644 index 77cb78108..000000000 --- a/docs/docs/sign-in-out.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Sign In and Out -layout: docs ---- -## Signing in and out -To sign in and out of Tableau Server, call the `Auth.sign_in` and `Auth.sign_out` functions like so: - -```py -import tableauserverclient as TSC - -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVER_URL') - -server.auth.sign_in(tableau_auth) - -# Do awesome things here! - -server.auth.sign_out() -``` - -Alternatively, for short programs, consider using a `with` block: - -```py -import tableauserverclient as TSC - -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVER_URL') - -with server.auth.sign_in(tableau_auth): - # Do awesome things here! - -# No need to call auth.sign_out() as the Auth context manager will handle that on exiting the with block -``` - -
- Note: When you sign in, the TSC library manages the authenticated session for you, however it is still - limited by the maximum session length (of four hours) on Tableau Server. -
- -### Disabling certificate verification and warnings -Certain environments may throw errors related to SSL configuration, such as self-signed certificates or expired certificates. These errors can be avoided by disabling certificate verification with ```add_http_options```: - -```py -import tableauserverclient as TSC - -tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') -server = TSC.Server('http://SERVER_URL') -server.add_http_options({'verify': False}) -``` - -However, each subsequent REST API call will print an ```InsecureRequestWarning```: -``` -InsecureRequestWarning: Unverified HTTPS request is being made. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/security.html -InsecureRequestWarning) -``` - -These warnings can be disabled by adding the following lines to your script: -```py -import requests -from requests.packages.urllib3.exceptions import InsecureRequestWarning -requests.packages.urllib3.disable_warnings(InsecureRequestWarning) -``` - -### A better way to avoid certificate warnings -Instead of disabling warnings and certificate verification to workaround issues with untrusted certificates, the best practice is to use a certificate signed by a Certificate Authority. - -If you have the ability to do so, we recommend the following Certificate Authorities: -* [GlobalSign](https://www.globalsign.com/en/) -* [Let's Encrypt](https://letsencrypt.org/) - a free, automated, and open Certificate Authority -* [SSL.com](https://www.ssl.com/) diff --git a/docs/docs/versions.md b/docs/docs/versions.md deleted file mode 100644 index eac3cdca2..000000000 --- a/docs/docs/versions.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: Versions -layout: docs ---- - -Because the TSC library is a client for the Tableau Server REST API, you need to confirm that the version of the TSC -library that you use is compatible with the version of the REST API used by your installation of Tableau Server. - -* TOC -{:toc} - -## Display the REST API version - -To display the default version of the REST API used by TSC, run the following code: - -```py -import tableauserverclient as TSC - -server = TSC.Server('http://SERVER_URL') - -print(server.version) -``` - -For example, the code might display version `2.3`. - -## Use another version of the REST API - -To use another version of the REST API, set the version like so: - -```py -import tableauserverclient as TSC - -server = TSC.Server('http://SERVER_URL') - - -server.version = '2.6' - - - -``` - -## Supported versions - -The current version of TSC only supports the following REST API and Tableau Server versions: - -|REST API version|Tableau Server version| -|---|---| -|2.3|10.0| -|2.4|10.1| -|2.5|10.2| -|2.6|10.3| From 942dbd77aa3fbf5912a3d7f61d8682c8a265560c Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 8 Aug 2019 17:45:48 -0700 Subject: [PATCH 095/567] Make it so the test-runner is only downloaded when running tests (#485) --- setup.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 2c8718d5d..1b8bc0f23 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,16 @@ +import sys import versioneer + try: from setuptools import setup except ImportError: from distutils.core import setup +# Only install pytest and runner when test command is run +# This makes work easier for offline installs or low bandwidth machines +needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) +pytest_runner = ['pytest-runner'] if needs_pytest else [] + setup( name='tableauserverclient', version=versioneer.get_version(), @@ -16,9 +23,7 @@ license='MIT', description='A Python module for working with the Tableau Server REST API.', test_suite='test', - setup_requires=[ - 'pytest-runner' - ], + setup_requires=pytest_runner, install_requires=[ 'requests>=2.11,<3.0' ], From 2c2ce918fb82d84509bd3af8b16d6f82b6615acd Mon Sep 17 00:00:00 2001 From: dzucker-tab <49076749+dzucker-tab@users.noreply.github.com> Date: Thu, 8 Aug 2019 18:21:38 -0700 Subject: [PATCH 096/567] Update readme.md (#483) * Update readme.md * Update readme.md --- docs/docs/readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/readme.md b/docs/docs/readme.md index 03e495ee5..0700899ab 100644 --- a/docs/docs/readme.md +++ b/docs/docs/readme.md @@ -1,3 +1,3 @@ -To view the documentation source for the Tableau Server CLient library, find the `doc` folder in the `gh-pages` branch of this repo. +To view the documentation source for the Tableau Server Client library, find the `doc` folder in the [`gh-pages`](https://github.com/tableau/server-client-python/tree/gh-pages/docs) branch of this repo. -To contribute, see the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide#update-the-documentation) page. \ No newline at end of file +For more info about contributing, see the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide#update-the-documentation) page. From 869ae40725a16f307d1245b3b51e3c28f0607a94 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 14 Aug 2019 09:21:11 -0700 Subject: [PATCH 097/567] Pin to urllib3 to fix encoding issue (#487) --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1b8bc0f23..a7b29aa90 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,8 @@ test_suite='test', setup_requires=pytest_runner, install_requires=[ - 'requests>=2.11,<3.0' + 'requests>=2.11,<3.0', + 'urllib3==1.24.3' ], tests_require=[ 'requests-mock>=1.0,<2.0', From 19c63229a7fed3d97d8d1d523bab4af719030f67 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 26 Aug 2019 09:52:30 -0700 Subject: [PATCH 098/567] External Content Support (Databases and Tables) (#445) Add the ability to query and update external content managed by Tableau Catalog. These APIs are read-only unless the Data Management Add-On is enabled on the Server/Online Site. Add: - Databases - Tables - Columns And permissions support preemptively. Permissions APIs are disabled until a 2019.3 maintenance release (2019.3.1 or 2019.3.2) --- tableauserverclient/__init__.py | 8 +- tableauserverclient/models/__init__.py | 3 + tableauserverclient/models/column_item.py | 69 +++++ tableauserverclient/models/database_item.py | 260 ++++++++++++++++++ .../models/permissions_item.py | 2 + tableauserverclient/models/table_item.py | 147 ++++++++++ tableauserverclient/server/__init__.py | 7 +- .../server/endpoint/__init__.py | 2 + .../server/endpoint/databases_endpoint.py | 108 ++++++++ .../server/endpoint/tables_endpoint.py | 108 ++++++++ tableauserverclient/server/request_factory.py | 53 ++++ tableauserverclient/server/server.py | 5 +- test/assets/database_get.xml | 24 ++ test/assets/database_populate_permissions.xml | 21 ++ test/assets/database_update.xml | 9 + test/assets/table_get.xml | 21 ++ test/assets/table_update.xml | 8 + test/test_database.py | 87 ++++++ test/test_table.py | 62 +++++ 19 files changed, 996 insertions(+), 8 deletions(-) create mode 100644 tableauserverclient/models/column_item.py create mode 100644 tableauserverclient/models/database_item.py create mode 100644 tableauserverclient/models/table_item.py create mode 100644 tableauserverclient/server/endpoint/databases_endpoint.py create mode 100644 tableauserverclient/server/endpoint/tables_endpoint.py create mode 100644 test/assets/database_get.xml create mode 100644 test/assets/database_populate_permissions.xml create mode 100644 test/assets/database_update.xml create mode 100644 test/assets/table_get.xml create mode 100644 test/assets/table_update.xml create mode 100644 test/test_database.py create mode 100644 test/test_table.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index d1b8a4e74..d435962b1 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,9 +1,9 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ - GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem, \ - SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \ - HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem, \ - SubscriptionItem, Target, PermissionsRule, Permission + 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 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 f96f78565..4cfdd4846 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,6 +1,8 @@ from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem +from .column_item import ColumnItem from .datasource_item import DatasourceItem +from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval @@ -13,6 +15,7 @@ from .tableau_auth import TableauAuth from .personal_access_token_auth import PersonalAccessTokenAuth from .target import Target +from .table_item import TableItem from .task_item import TaskItem from .user_item import UserItem from .view_item import ViewItem diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py new file mode 100644 index 000000000..475dd0e2a --- /dev/null +++ b/tableauserverclient/models/column_item.py @@ -0,0 +1,69 @@ +import xml.etree.ElementTree as ET + +from .property_decorators import property_is_enum, property_not_empty +from .exceptions import UnpopulatedPropertyError + + +class ColumnItem(object): + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = ET.fromstring(resp) + all_column_xml = parsed_response.findall('.//t:column', namespaces=ns) + + for column_xml in all_column_xml: + (id, name, description, remote_type) = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get('id', None) + name = column_xml.get('name', None) + description = column_xml.get('description', None) + remote_type = column_xml.get('remoteType', None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py new file mode 100644 index 000000000..cb7ebf5f0 --- /dev/null +++ b/tableauserverclient/models/database_item.py @@ -0,0 +1,260 @@ +import xml.etree.ElementTree as ET + +from .permissions_item import Permission + +from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .exceptions import UnpopulatedPropertyError + + +class DatabaseItem(object): + class ContentPermissions: + LockedToProject = 'LockedToDatabase' + ManagedByOwner = 'ManagedByOwner' + + def __init__(self, name, description=None, content_permissions=None): + self._id = None + self.name = name + self.description = description + self.content_permissions = content_permissions + self._certified = None + self._certification_note = None + self._contact_id = None + + self._connector_url = None + self._connection_type = None + self._embedded = None + self._file_extension = None + self._file_id = None + self._file_path = None + self._host_name = None + self._metadata_type = None + self._mime_type = None + self._port = None + self._provider = None + self._request_url = None + + self._permissions = None + self._default_table_permissions = None + + self._tables = None # Not implemented yet + + @property + def content_permissions(self): + return self._content_permissions + + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_table_permissions() + + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value): + self._content_permissions = value + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def embedded(self): + return self._embedded + + @property + def certified(self): + return self._certified + + @certified.setter + @property_is_boolean + def certified(self, value): + self._certified = value + + @property + def certification_note(self): + return self._certification_note + + @certification_note.setter + def certification_note(self, value): + self._certification_note = value + + @property + def metadata_type(self): + return self._metadata_type + + @property + def host_name(self): + return self._host_name + + @property + def port(self): + return self._port + + @property + def file_path(self): + return self._file_path + + @property + def provider(self): + return self._provider + + @property + def mime_type(self): + return self._mime_type + + @property + def connector_url(self): + return self._connector_url + + @property + def connection_type(self): + return self._connection_type + + @property + def request_url(self): + return self._request_url + + @property + def file_extension(self): + return self._file_extension + + @property + def file_id(self): + return self._file_id + + @property + def contact_id(self): + return self._contact_id + + @contact_id.setter + def contact_id(self, value): + self._contact_id = value + + @property + def tables(self): + if self._tables is None: + error = "Database must be populated with tables first." + raise UnpopulatedPropertyError(error) + # Each call to `.tables` should create a new pager, this just runs the callable + return self._tables() + + def _set_values(self, database_values): + # ID & Settable + if 'id' in database_values: + self._id = database_values['id'] + + if 'contact' in database_values: + self._contact_id = database_values['contact']['id'] + + if 'name' in database_values: + self._name = database_values['name'] + + if 'description' in database_values: + self._description = database_values['description'] + + if 'isCertified' in database_values: + self._certified = string_to_bool(database_values['isCertified']) + + if 'certificationNote' in database_values: + self._certification_note = database_values['certificationNote'] + + # Not settable, alphabetical + + if 'connectionType' in database_values: + self._connection_type = database_values['connectionType'] + + if 'connectorUrl' in database_values: + self._connector_url = database_values['connectorUrl'] + + if 'contentPermissions' in database_values: + self._content_permissions = database_values['contentPermissions'] + + if 'embedded' in database_values: + self._embedded = string_to_bool(database_values['embedded']) + + if 'fileExtension' in database_values: + self._file_extension = database_values['fileExtension'] + + if 'fileId' in database_values: + self._file_id = database_values['fileId'] + + if 'filePath' in database_values: + self._file_path = database_values['filePath'] + + if 'hostName' in database_values: + self._host_name = database_values['hostName'] + + if 'mimeType' in database_values: + self._mime_type = database_values['mimeType'] + + if 'port' in database_values: + self._port = int(database_values['port']) + + if 'provider' in database_values: + self._provider = database_values['provider'] + + if 'requestUrl' in database_values: + self._request_url = database_values['requestUrl'] + + if 'type' in database_values: + self._metadata_type = database_values['type'] + + def _set_permissions(self, permissions): + self._permissions = permissions + + def _set_tables(self, tables): + self._tables = tables + + def _set_default_permissions(self, permissions, content_type): + setattr(self, "_default_{content}_permissions".format(content=content_type), permissions) + + @classmethod + def from_response(cls, resp, ns): + all_database_items = list() + parsed_response = ET.fromstring(resp) + all_database_xml = parsed_response.findall('.//t:database', namespaces=ns) + + for database_xml in all_database_xml: + parsed_database = cls._parse_element(database_xml, ns) + database_item = cls(parsed_database['name']) + database_item._set_values(parsed_database) + all_database_items.append(database_item) + return all_database_items + + @staticmethod + def _parse_element(database_xml, ns): + database_values = database_xml.attrib.copy() + contact = database_xml.find('.//t:contact', namespaces=ns) + if contact is not None: + database_values['contact'] = contact.attrib.copy() + return database_values + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 2c2abdf82..6487b6ca5 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -36,6 +36,8 @@ class Resource: Workbook = 'workbook' Datasource = 'datasource' Flow = 'flow' + Table = 'table' + Database = 'database' class PermissionsRule(object): diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py new file mode 100644 index 000000000..c3debc0b7 --- /dev/null +++ b/tableauserverclient/models/table_item.py @@ -0,0 +1,147 @@ +import xml.etree.ElementTree as ET + +from .permissions_item import Permission +from .column_item import ColumnItem + +from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .exceptions import UnpopulatedPropertyError + + +class TableItem(object): + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + self._contact_id = None + self._certified = None + self._certification_note = None + self._permissions = None + self._schema = None + + self._columns = None + + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def certified(self): + return self._certified + + @certified.setter + @property_is_boolean + def certified(self, value): + self._certified = value + + @property + def certification_note(self): + return self._certification_note + + @certification_note.setter + def certification_note(self, value): + self._certification_note = value + + @property + def contact_id(self): + return self._contact_id + + @contact_id.setter + def contact_id(self, value): + self._contact_id = value + + @property + def schema(self): + return self._schema + + @property + def columns(self): + if self._columns is None: + error = "Table must be populated with columns first." + raise UnpopulatedPropertyError(error) + # Each call to `.columns` should create a new pager, this just runs the callable + return self._columns() + + def _set_columns(self, columns): + self._columns = columns + + def _set_values(self, table_values): + if 'id' in table_values: + self._id = table_values['id'] + + if 'name' in table_values: + self._name = table_values['name'] + + if 'description' in table_values: + self._description = table_values['description'] + + if 'isCertified' in table_values: + self._certified = string_to_bool(table_values['isCertified']) + + if 'certificationNote' in table_values: + self._certification_note = table_values['certificationNote'] + + if 'embedded' in table_values: + self._embedded = string_to_bool(table_values['embedded']) + + if 'schema' in table_values: + self._schema = table_values['schema'] + + if 'contact' in table_values: + self._contact_id = table_values['contact']['id'] + + def _set_permissions(self, permissions): + self._permissions = permissions + + @classmethod + def from_response(cls, resp, ns): + all_table_items = list() + parsed_response = ET.fromstring(resp) + all_table_xml = parsed_response.findall('.//t:table', namespaces=ns) + + for table_xml in all_table_xml: + parsed_table = cls._parse_element(table_xml, ns) + table_item = cls(parsed_table["name"]) + table_item._set_values(parsed_table) + all_table_items.append(table_item) + return all_table_items + + @staticmethod + def _parse_element(table_xml, ns): + + table_values = table_xml.attrib.copy() + + contact = table_xml.find('.//t:contact', namespaces=ns) + if contact is not None: + table_values['contact'] = contact.attrib.copy() + + return table_values + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == 'true' diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 7fa59ef3c..dcbdc8d13 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,11 +2,12 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, JobItem, BackgroundJobItem, \ +from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ - UserItem, ViewItem, WorkbookItem, TaskItem, SubscriptionItem, PermissionsRule, Permission + UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ + PermissionsRule, Permission, ColumnItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ - Sites, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ + Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError from .server import Server from .pager import Pager diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 24881b2e4..99bb37005 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,5 +1,6 @@ from .auth_endpoint import Auth from .datasources_endpoint import Datasources +from .databases_endpoint import Databases from .endpoint import Endpoint from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups @@ -9,6 +10,7 @@ from .schedules_endpoint import Schedules from .server_info_endpoint import ServerInfo from .sites_endpoint import Sites +from .tables_endpoint import Tables from .tasks_endpoint import Tasks from .users_endpoint import Users from .views_endpoint import Views diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py new file mode 100644 index 000000000..c0726abe2 --- /dev/null +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -0,0 +1,108 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from .. import RequestFactory, DatabaseItem, PaginationItem, PermissionsRule, Permission + +import logging + +logger = logging.getLogger('tableau.endpoint.databases') + + +class Databases(Endpoint): + def __init__(self, parent_srv): + super(Databases, self).__init__(parent_srv) + + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.5") + def get(self, req_options=None): + logger.info('Querying all databases on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_database_items = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace) + return all_database_items, pagination_item + + # Get 1 database + @api(version="3.5") + def get_by_id(self, database_id): + if not database_id: + error = "database ID undefined." + raise ValueError(error) + logger.info('Querying single database (ID: {0})'.format(database_id)) + url = "{0}/{1}".format(self.baseurl, database_id) + server_response = self.get_request(url) + return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.5") + def delete(self, database_id): + if not database_id: + error = "Database ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, database_id) + self.delete_request(url) + logger.info('Deleted single database (ID: {0})'.format(database_id)) + + @api(version="3.5") + def update(self, database_item): + if not database_item.id: + error = "Database item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, database_item.id) + update_req = RequestFactory.Database.update_req(database_item) + server_response = self.put_request(url, update_req) + logger.info('Updated database item (ID: {0})'.format(database_item.id)) + updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_database + + # Not Implemented Yet + @api(version="99") + def populate_tables(self, database_item): + if not database_item.id: + error = "database item missing ID. database must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def column_fetcher(): + return self._get_tables_for_database(database_item) + + database_item._set_tables(column_fetcher) + logger.info('Populated tables for database (ID: {0}'.format(database_item.id)) + + def _get_tables_for_database(self, database_item): + url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + server_response = self.get_request(url) + tables = TableItem.from_response(server_response.content, + self.parent_srv.namespace) + return tables + + @api(version='3.5') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.5') + def update_permission(self, item, rules): + return self._permissions.update(item, rules) + + @api(version='3.5') + def delete_permission(self, item, rules): + return self._permissions.delete(item, rules) + + @api(version='3.5') + def populate_table_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Table) + + @api(version='3.5') + def update_table_default_permissions(self, item): + self._default_permissions.update_default_permissions(item, Permission.Resource.Table) + + @api(version='3.5') + def delete_table_default_permissions(self, item): + self._default_permissions.delete_default_permissions(item, Permission.Resource.Table) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py new file mode 100644 index 000000000..b8430a124 --- /dev/null +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -0,0 +1,108 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from ..pager import Pager + +from .. import RequestFactory, TableItem, ColumnItem, PaginationItem, PermissionsRule, Permission + +import logging + +logger = logging.getLogger('tableau.endpoint.tables') + + +class Tables(Endpoint): + def __init__(self, parent_srv): + super(Tables, self).__init__(parent_srv) + + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.5") + def get(self, req_options=None): + logger.info('Querying all tables on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_table_items = TableItem.from_response(server_response.content, self.parent_srv.namespace) + return all_table_items, pagination_item + + # Get 1 table + @api(version="3.5") + def get_by_id(self, table_id): + if not table_id: + error = "table ID undefined." + raise ValueError(error) + logger.info('Querying single table (ID: {0})'.format(table_id)) + url = "{0}/{1}".format(self.baseurl, table_id) + server_response = self.get_request(url) + return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.5") + def delete(self, table_id): + if not table_id: + error = "Database ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, table_id) + self.delete_request(url) + logger.info('Deleted single table (ID: {0})'.format(table_id)) + + @api(version="3.5") + def update(self, table_item): + if not table_item.id: + error = "table item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, table_item.id) + update_req = RequestFactory.Table.update_req(table_item) + server_response = self.put_request(url, update_req) + logger.info('Updated table item (ID: {0})'.format(table_item.id)) + updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_table + + # Get all columns of the table + @api(version="3.5") + def populate_columns(self, table_item, req_options=None): + if not table_item.id: + error = "Table item missing ID. table must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def column_fetcher(): + return Pager(lambda options: self._get_columns_for_table(table_item, options), req_options) + + table_item._set_columns(column_fetcher) + logger.info('Populated columns for table (ID: {0}'.format(table_item.id)) + + def _get_columns_for_table(self, table_item, req_options=None): + url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + server_response = self.get_request(url, req_options) + columns = ColumnItem.from_response(server_response.content, + self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + return columns, pagination_item + + @api(version="3.5") + def update_column(self, table_item, column_item): + url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + update_req = RequestFactory.Column.update_req(column_item) + server_response = self.put_request(url, update_req) + column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Updated table item (ID: {0} & column item {1}'.format(table_item.id, + column_item.id)) + return column + + @api(version='3.5') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.5') + def update_permission(self, item, rules): + return self._permissions.update(item, rules) + + @api(version='3.5') + def delete_permission(self, item, rules): + return self._permissions.delete(item, rules) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b6739af6b..1f787382e 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -63,6 +63,36 @@ def signin_req(self, auth_item): return ET.tostring(xml_request) +class ColumnRequest(object): + def update_req(self, column_item): + xml_request = ET.Element('tsRequest') + column_element = ET.SubElement(xml_request, 'column') + + if column_item.description: + column_element.attrib['description'] = str(column_item.description) + + return ET.tostring(xml_request) + + +class DatabaseRequest(object): + def update_req(self, database_item): + xml_request = ET.Element('tsRequest') + database_element = ET.SubElement(xml_request, 'database') + if database_item.contact_id: + contact_element = ET.SubElement(database_element, 'contact') + contact_element.attrib['id'] = database_item.contact_id + + database_element.attrib['isCertified'] = str(database_item.certified).lower() + + if database_item.certification_note: + database_element.attrib['certificationNote'] = str(database_item.certification_note) + + if database_item.description: + database_element.attrib['description'] = str(database_item.description) + + return ET.tostring(xml_request) + + class DatasourceRequest(object): def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): xml_request = ET.Element('tsRequest') @@ -317,6 +347,26 @@ def create_req(self, site_item): return ET.tostring(xml_request) +class TableRequest(object): + def update_req(self, table_item): + xml_request = ET.Element('tsRequest') + table_element = ET.SubElement(xml_request, 'table') + + if table_item.contact_id: + contact_element = ET.SubElement(table_element, 'contact') + contact_element.attrib['id'] = table_item.contact_id + + table_element.attrib['isCertified'] = str(table_item.certified).lower() + + if table_item.certification_note: + table_element.attrib['certificationNote'] = str(table_item.certification_note) + + if table_item.description: + table_element.attrib['description'] = str(table_item.description) + + return ET.tostring(xml_request) + + class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element('tsRequest') @@ -468,7 +518,9 @@ def empty_req(self, xml_request): class RequestFactory(object): Auth = AuthRequest() Connection = Connection() + Column = ColumnRequest() Datasource = DatasourceRequest() + Database = DatabaseRequest() Empty = EmptyRequest() Fileupload = FileuploadRequest() Group = GroupRequest() @@ -476,6 +528,7 @@ class RequestFactory(object): Project = ProjectRequest() Schedule = ScheduleRequest() Site = SiteRequest() + Table = TableRequest() Tag = TagRequest() Task = TaskRequest() User = UserRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 536b3982a..9ba195d9d 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,7 +3,8 @@ from .exceptions import NotSignedInError from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata + Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ + Databases, Tables from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -51,6 +52,8 @@ def __init__(self, server_address, use_server_version=False): self.tasks = Tasks(self) self.subscriptions = Subscriptions(self) self.metadata = Metadata(self) + self.databases = Databases(self) + self.tables = Tables(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/database_get.xml b/test/assets/database_get.xml new file mode 100644 index 000000000..7d22daf4c --- /dev/null +++ b/test/assets/database_get.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/database_populate_permissions.xml b/test/assets/database_populate_permissions.xml new file mode 100644 index 000000000..21f30fea9 --- /dev/null +++ b/test/assets/database_populate_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/database_update.xml b/test/assets/database_update.xml new file mode 100644 index 000000000..b2cbd68c9 --- /dev/null +++ b/test/assets/database_update.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/table_get.xml b/test/assets/table_get.xml new file mode 100644 index 000000000..0bd2763d5 --- /dev/null +++ b/test/assets/table_get.xml @@ -0,0 +1,21 @@ + + + + + + + +
+ + + +
+ + +
+ + +
+
+
\ No newline at end of file diff --git a/test/assets/table_update.xml b/test/assets/table_update.xml new file mode 100644 index 000000000..975f0cedb --- /dev/null +++ b/test/assets/table_update.xml @@ -0,0 +1,8 @@ + + + + + +
+
\ No newline at end of file diff --git a/test/test_database.py b/test/test_database.py new file mode 100644 index 000000000..fb9ffbd86 --- /dev/null +++ b/test/test_database.py @@ -0,0 +1,87 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'database_get.xml' +POPULATE_PERMISSIONS_XML = 'database_populate_permissions.xml' +UPDATE_XML = 'database_update.xml' + + +class DatabaseTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.databases.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_databases, pagination_item = self.server.databases.get() + + self.assertEqual(5, pagination_item.total_available) + self.assertEqual('5ea59b45-e497-4827-8809-bfe213236f75', all_databases[0].id) + self.assertEqual('hyper', all_databases[0].connection_type) + self.assertEqual('hyper_0.hyper', all_databases[0].name) + + self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', all_databases[1].id) + self.assertEqual('sqlserver', all_databases[1].connection_type) + self.assertEqual('testv1', all_databases[1].name) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_databases[1].contact_id) + self.assertEqual(True, all_databases[1].certified) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/23591f2c-4802-4d6a-9e28-574a8ea9bc4c', text=response_xml) + single_database = TSC.DatabaseItem('test') + single_database.contact_id = '9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0' + single_database._id = '23591f2c-4802-4d6a-9e28-574a8ea9bc4c' + single_database.certified = True + single_database.certification_note = "Test" + single_database = self.server.databases.update(single_database) + + self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', single_database.id) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', single_database.contact_id) + self.assertEqual(True, single_database.certified) + self.assertEqual("Test", single_database.certification_note) + + def test_populate_permissions(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + single_database = TSC.DatabaseItem('test') + single_database._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.databases.populate_permissions(single_database) + permissions = single_database.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }) + + self.assertEqual(permissions[1].grantee.tag_name, 'user') + self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions[1].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.databases.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') diff --git a/test/test_table.py b/test/test_table.py new file mode 100644 index 000000000..45af43c9a --- /dev/null +++ b/test/test_table.py @@ -0,0 +1,62 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'table_get.xml' +UPDATE_XML = 'table_update.xml' + + +class TableTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.tables.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_tables, pagination_item = self.server.tables.get() + + self.assertEqual(4, pagination_item.total_available) + self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', all_tables[0].id) + self.assertEqual('dim_Product', all_tables[0].name) + + self.assertEqual('53c77bc1-fb41-4342-a75a-f68ac0656d0d', all_tables[1].id) + self.assertEqual('customer', all_tables[1].name) + self.assertEqual('dbo', all_tables[1].schema) + self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_tables[1].contact_id) + self.assertEqual(False, all_tables[1].certified) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/10224773-ecee-42ac-b822-d786b0b8e4d9', text=response_xml) + single_table = TSC.TableItem('test') + single_table._id = '10224773-ecee-42ac-b822-d786b0b8e4d9' + + single_table.contact_id = '8e1a8235-c9ee-4d61-ae82-2ffacceed8e0' + single_table.certified = True + single_table.certification_note = "Test" + single_table = self.server.tables.update(single_table) + + self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', single_table.id) + self.assertEqual('8e1a8235-c9ee-4d61-ae82-2ffacceed8e0', single_table.contact_id) + self.assertEqual(True, single_table.certified) + self.assertEqual("Test", single_table.certification_note) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.tables.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') From f3cf8436db7aef1f19a23df39907a53e0da5bbc1 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 26 Aug 2019 13:29:33 -0700 Subject: [PATCH 099/567] embedded fix (#492) embedded -> isEmbedded for DB/Table Items --- tableauserverclient/models/database_item.py | 4 ++-- tableauserverclient/models/table_item.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index cb7ebf5f0..9aecca6cc 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -194,8 +194,8 @@ def _set_values(self, database_values): if 'contentPermissions' in database_values: self._content_permissions = database_values['contentPermissions'] - if 'embedded' in database_values: - self._embedded = string_to_bool(database_values['embedded']) + if 'isEmbedded' in database_values: + self._embedded = string_to_bool(database_values['isEmbedded']) if 'fileExtension' in database_values: self._file_extension = database_values['fileExtension'] diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index c3debc0b7..8d8f63674 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -105,8 +105,8 @@ def _set_values(self, table_values): if 'certificationNote' in table_values: self._certification_note = table_values['certificationNote'] - if 'embedded' in table_values: - self._embedded = string_to_bool(table_values['embedded']) + if 'isEmbedded' in table_values: + self._embedded = string_to_bool(table_values['isEmbedded']) if 'schema' in table_values: self._schema = table_values['schema'] From fa6d444cc1b5f7937fe8c59a16d37f7fd29290d0 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 3 Sep 2019 13:48:23 -0700 Subject: [PATCH 100/567] Prep Flow Support (#494) Add support for Prep Flows on Server. --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/flow_item.py | 162 +++++++++++++ tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 1 + .../server/endpoint/flows_endpoint.py | 215 ++++++++++++++++++ tableauserverclient/server/request_factory.py | 41 ++++ tableauserverclient/server/server.py | 3 +- test/assets/flow_get.xml | 29 +++ test/assets/flow_populate_connections.xml | 8 + test/assets/flow_populate_permissions.xml | 15 ++ test/assets/flow_update.xml | 10 + test/test_flow.py | 115 ++++++++++ 13 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 tableauserverclient/models/flow_item.py create mode 100644 tableauserverclient/server/endpoint/flows_endpoint.py create mode 100644 test/assets/flow_get.xml create mode 100644 test/assets/flow_populate_connections.xml create mode 100644 test/assets/flow_populate_permissions.xml create mode 100644 test/assets/flow_update.xml create mode 100644 test/test_flow.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index d435962b1..eb647ed25 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,7 @@ 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 + SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem 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 4cfdd4846..a3517e13f 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem +from .flow_item import FlowItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py new file mode 100644 index 000000000..790000df2 --- /dev/null +++ b/tableauserverclient/models/flow_item.py @@ -0,0 +1,162 @@ +import xml.etree.ElementTree as ET +from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_nullable, property_is_boolean +from .tag_item import TagItem +from ..datetime_helpers import parse_datetime +import copy + + +class FlowItem(object): + def __init__(self, project_id, name=None): + self._webpage_url = None + self._created_at = None + self._id = None + self._initial_tags = set() + self._project_name = None + self._updated_at = None + self.name = name + self.owner_id = None + self.project_id = project_id + self.tags = set() + self.description = None + + self._connections = None + self._permissions = None + + @property + def connections(self): + if self._connections is None: + error = 'Flow item must be populated with connections first.' + raise UnpopulatedPropertyError(error) + return self._connections() + + @property + def permissions(self): + if self._permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def webpage_url(self): + return self._webpage_url + + @property + def created_at(self): + return self._created_at + + @property + def id(self): + return self._id + + @property + def project_id(self): + return self._project_id + + @project_id.setter + @property_not_nullable + def project_id(self, value): + self._project_id = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def project_name(self): + return self._project_name + + @property + def flow_type(self): + return self._flow_type + + @property + def updated_at(self): + return self._updated_at + + def _set_connections(self, connections): + self._connections = connections + + def _set_permissions(self, permissions): + self._permissions = permissions + + def _parse_common_elements(self, flow_xml, ns): + if not isinstance(flow_xml, ET.Element): + flow_xml = ET.fromstring(flow_xml).find('.//t:flow', namespaces=ns) + if flow_xml is not None: + (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id) = self._parse_element(flow_xml, ns) + self._set_values(None, None, None, None, None, updated_at, None, project_id, + project_name, owner_id) + return self + + def _set_values(self, id, name, description, webpage_url, created_at, + updated_at, tags, project_id, project_name, owner_id): + if id is not None: + self._id = id + if name: + self.name = name + if description: + self.description = description + if webpage_url: + self._webpage_url = webpage_url + if created_at: + self._created_at = created_at + if updated_at: + self._updated_at = updated_at + if tags: + self.tags = tags + self._initial_tags = copy.copy(tags) + if project_id: + self.project_id = project_id + if project_name: + self._project_name = project_name + if owner_id: + self.owner_id = owner_id + + @classmethod + def from_response(cls, resp, ns): + all_flow_items = list() + parsed_response = ET.fromstring(resp) + all_flow_xml = parsed_response.findall('.//t:flow', namespaces=ns) + + for flow_xml in all_flow_xml: + (id_, name, description, webpage_url, created_at, updated_at, + tags, project_id, project_name, owner_id) = cls._parse_element(flow_xml, ns) + flow_item = cls(project_id) + flow_item._set_values(id_, name, description, webpage_url, created_at, updated_at, + tags, None, project_name, owner_id) + all_flow_items.append(flow_item) + return all_flow_items + + @staticmethod + def _parse_element(flow_xml, ns): + id_ = flow_xml.get('id', None) + name = flow_xml.get('name', None) + description = flow_xml.get('description', None) + webpage_url = flow_xml.get('webpageUrl', None) + created_at = parse_datetime(flow_xml.get('createdAt', None)) + updated_at = parse_datetime(flow_xml.get('updatedAt', None)) + + tags = None + tags_elem = flow_xml.find('.//t:tags', namespaces=ns) + if tags_elem is not None: + tags = TagItem.from_xml_element(tags_elem, ns) + + project_id = None + project_name = None + project_elem = flow_xml.find('.//t:project', namespaces=ns) + if project_elem is not None: + project_id = project_elem.get('id', None) + project_name = project_elem.get('name', None) + + owner_id = None + owner_elem = flow_xml.find('.//t:owner', namespaces=ns) + if owner_elem is not None: + owner_id = owner_elem.get('id', None) + + return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id, + project_name, owner_id) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index dcbdc8d13..a76fd3246 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,10 +5,10 @@ from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ - PermissionsRule, Permission, ColumnItem + PermissionsRule, Permission, ColumnItem, FlowItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ - MissingRequiredFieldError + MissingRequiredFieldError, Flows from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 99bb37005..dbf501fe3 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -2,6 +2,7 @@ from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint +from .flows_endpoint import Flows from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups from .jobs_endpoint import Jobs diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py new file mode 100644 index 000000000..b2c616959 --- /dev/null +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -0,0 +1,215 @@ +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 +from .resource_tagger import _ResourceTagger +from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem +from ...filesys_helpers import to_filename +from ...models.tag_item import TagItem +from ...models.job_item import JobItem +import os +import logging +import copy +import cgi +from contextlib import closing + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB + +ALLOWED_FILE_EXTENSIONS = ['tfl', 'tflx'] + +logger = logging.getLogger('tableau.endpoint.flows') + + +class Flows(Endpoint): + def __init__(self, parent_srv): + super(Flows, self).__init__(parent_srv) + self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Get all flows + @api(version="3.3") + def get(self, req_options=None): + logger.info('Querying all flows on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_flow_items = FlowItem.from_response(server_response.content, self.parent_srv.namespace) + return all_flow_items, pagination_item + + # Get 1 flow by id + @api(version="3.3") + def get_by_id(self, flow_id): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + logger.info('Querying single flow (ID: {0})'.format(flow_id)) + url = "{0}/{1}".format(self.baseurl, flow_id) + server_response = self.get_request(url) + return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + # Populate flow item's connections + @api(version="3.3") + def populate_connections(self, flow_item): + if not flow_item.id: + error = 'Flow item missing ID. Flow must be retrieved from server first.' + raise MissingRequiredFieldError(error) + + def connections_fetcher(): + return self._get_flow_connections(flow_item) + + flow_item._set_connections(connections_fetcher) + logger.info('Populated connections for flow (ID: {0})'.format(flow_item.id)) + + def _get_flow_connections(self, flow_item, req_options=None): + url = '{0}/{1}/connections'.format(self.baseurl, flow_item.id) + server_response = self.get_request(url, req_options) + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + return connections + + # Delete 1 flow by id + @api(version="3.3") + def delete(self, flow_id): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, flow_id) + self.delete_request(url) + logger.info('Deleted single flow (ID: {0})'.format(flow_id)) + + # Download 1 flow by id + @api(version="3.3") + def download(self, flow_id, filepath=None): + if not flow_id: + error = "Flow ID undefined." + raise ValueError(error) + url = "{0}/{1}/content".format(self.baseurl, flow_id) + + with closing(self.get_request(url, parameters={'stream': True})) as server_response: + _, params = cgi.parse_header(server_response.headers['Content-Disposition']) + filename = to_filename(os.path.basename(params['filename'])) + if filepath is None: + filepath = filename + elif os.path.isdir(filepath): + filepath = os.path.join(filepath, filename) + + with open(filepath, 'wb') as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + + logger.info('Downloaded flow to {0} (ID: {1})'.format(filepath, flow_id)) + return os.path.abspath(filepath) + + # Update flow + @api(version="3.3") + def update(self, flow_item): + if not flow_item.id: + error = 'Flow item missing ID. Flow must be retrieved from server first.' + raise MissingRequiredFieldError(error) + + self._resource_tagger.update_tags(self.baseurl, flow_item) + + # Update the flow itself + url = "{0}/{1}".format(self.baseurl, flow_item.id) + update_req = RequestFactory.Flow.update_req(flow_item) + server_response = self.put_request(url, update_req) + logger.info('Updated flow item (ID: {0})'.format(flow_item.id)) + updated_flow = copy.copy(flow_item) + return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) + + # Update flow connections + @api(version="3.3") + def update_connection(self, flow_item, connection_item): + url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + + update_req = RequestFactory.Connection.update_req(connection_item) + server_response = self.put_request(url, update_req) + connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Updated flow item (ID: {0} & connection item {1}'.format(flow_item.id, + connection_item.id)) + return connection + + @api(version="3.3") + def refresh(self, flow_item): + url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + # Publish flow + @api(version="3.3") + def publish(self, flow_item, file_path, mode, connections=None): + if not os.path.isfile(file_path): + error = "File path does not lead to an existing file." + raise IOError(error) + if not mode or not hasattr(self.parent_srv.PublishMode, mode): + error = 'Invalid mode defined.' + raise ValueError(error) + + filename = os.path.basename(file_path) + file_extension = os.path.splitext(filename)[1][1:] + + # If name is not defined, grab the name from the file to publish + if not flow_item.name: + flow_item.name = os.path.splitext(filename)[0] + if file_extension not in ALLOWED_FILE_EXTENSIONS: + error = "Only {} files can be published as flows.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) + raise ValueError(error) + + # Construct the url with the defined mode + url = "{0}?flowType={1}".format(self.baseurl, file_extension) + if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: + url += '&{0}=true'.format(mode.lower()) + + # Determine if chunking is required (64MB is the limit for single upload method) + if os.path.getsize(file_path) >= FILESIZE_LIMIT: + logger.info('Publishing {0} to server with chunking method (flow over 64MB)'.format(filename)) + upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, + connections) + else: + logger.info('Publishing {0} to server'.format(filename)) + with open(file_path, 'rb') as f: + file_contents = f.read() + xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, + filename, + file_contents, + connections) + + # Send the publishing request to server + try: + server_response = self.post_request(url, xml_request, content_type) + except InternalServerError as err: + if err.code == 504: + err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + raise err + else: + new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + return new_flow + + server_response = self.post_request(url, xml_request, content_type) + new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + return new_flow + + @api(version='3.3') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.3') + def update_permission(self, item, permission_item): + self._permissions.update(item, permission_item) + + @api(version='3.3') + def delete_permission(self, item, capability_item): + self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 1f787382e..ad484e6a8 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -151,6 +151,46 @@ def chunk_req(self, chunk): return _add_multipart(parts) +class FlowRequest(object): + def _generate_xml(self, flow_item, connections=None): + xml_request = ET.Element('tsRequest') + flow_element = ET.SubElement(xml_request, 'flow') + flow_element.attrib['name'] = flow_item.name + project_element = ET.SubElement(flow_element, 'project') + project_element.attrib['id'] = flow_item.project_id + + if connections is not None: + connections_element = ET.SubElement(flow_element, 'connections') + for connection in connections: + _add_connections_element(connections_element, connection) + return ET.tostring(xml_request) + + def update_req(self, flow_item): + xml_request = ET.Element('tsRequest') + flow_element = ET.SubElement(xml_request, 'flow') + if flow_item.project_id: + project_element = ET.SubElement(flow_element, 'project') + project_element.attrib['id'] = flow_item.project_id + if flow_item.owner_id: + owner_element = ET.SubElement(flow_element, 'owner') + owner_element.attrib['id'] = flow_item.owner_id + + return ET.tostring(xml_request) + + def publish_req(self, flow_item, filename, file_contents, connections=None): + xml_request = self._generate_xml(flow_item, connections) + + parts = {'request_payload': ('', xml_request, 'text/xml'), + 'tableau_flow': (filename, file_contents, 'application/octet-stream')} + return _add_multipart(parts) + + def publish_req_chunked(self, flow_item, connections=None): + xml_request = self._generate_xml(flow_item, connections) + + parts = {'request_payload': ('', xml_request, 'text/xml')} + return _add_multipart(parts) + + class GroupRequest(object): def add_user_req(self, user_id): xml_request = ET.Element('tsRequest') @@ -523,6 +563,7 @@ class RequestFactory(object): Database = DatabaseRequest() Empty = EmptyRequest() Fileupload = FileuploadRequest() + Flow = FlowRequest() Group = GroupRequest() Permission = PermissionRequest() Project = ProjectRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 9ba195d9d..b11f55d17 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 + Databases, Tables, Flows from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -46,6 +46,7 @@ def __init__(self, server_address, use_server_version=False): self.jobs = Jobs(self) self.workbooks = Workbooks(self) self.datasources = Datasources(self) + self.flows = Flows(self) self.projects = Projects(self) self.schedules = Schedules(self) self.server_info = ServerInfo(self) diff --git a/test/assets/flow_get.xml b/test/assets/flow_get.xml new file mode 100644 index 000000000..406cded8e --- /dev/null +++ b/test/assets/flow_get.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_populate_connections.xml b/test/assets/flow_populate_connections.xml new file mode 100644 index 000000000..5c013770c --- /dev/null +++ b/test/assets/flow_populate_connections.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml new file mode 100644 index 000000000..59fe5bd67 --- /dev/null +++ b/test/assets/flow_populate_permissions.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_update.xml b/test/assets/flow_update.xml new file mode 100644 index 000000000..5ab69f583 --- /dev/null +++ b/test/assets/flow_update.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/test_flow.py b/test/test_flow.py new file mode 100644 index 000000000..f5c057c30 --- /dev/null +++ b/test/test_flow.py @@ -0,0 +1,115 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'flow_get.xml' +POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml' +POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml' +UPDATE_XML = 'flow_update.xml' + + +class FlowTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.5" + + self.baseurl = self.server.flows.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_flows, pagination_item = self.server.flows.get() + + self.assertEqual(5, pagination_item.total_available) + self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', all_flows[0].id) + self.assertEqual('http://tableauserver/#/flows/1', all_flows[0].webpage_url) + self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].created_at)) + self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].updated_at)) + self.assertEqual('Default', all_flows[0].project_name) + self.assertEqual('FlowOne', all_flows[0].name) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[0].project_id) + self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', all_flows[0].owner_id) + self.assertEqual({'i_love_tags'}, all_flows[0].tags) + self.assertEqual('Descriptive', all_flows[0].description) + + self.assertEqual('5c36be69-eb30-461b-b66e-3e2a8e27cc35', all_flows[1].id) + self.assertEqual('http://tableauserver/#/flows/4', all_flows[1].webpage_url) + self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].created_at)) + self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].updated_at)) + self.assertEqual('Default', all_flows[1].project_name) + self.assertEqual('FlowTwo', all_flows[1].name) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[1].project_id) + self.assertEqual('9127d03f-d996-405f-b392-631b25183a0f', all_flows[1].owner_id) + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/587daa37-b84d-4400-a9a2-aa90e0be7837', text=response_xml) + single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') + single_datasource.owner_id = '7ebb3f20-0fd2-4f27-a2f6-c539470999e2' + single_datasource._id = '587daa37-b84d-4400-a9a2-aa90e0be7837' + single_datasource.description = "So fun to see" + single_datasource = self.server.flows.update(single_datasource) + + self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', single_datasource.id) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', single_datasource.project_id) + self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', single_datasource.owner_id) + self.assertEqual("So fun to see", single_datasource.description) + + def test_populate_connections(self): + response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) + single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') + single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + self.server.flows.populate_connections(single_datasource) + self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) + connections = single_datasource.connections + + self.assertTrue(connections) + conn1, conn2, conn3 = connections + self.assertEqual('405c1e4b-60c9-499f-9c47-a4ef1af69359', conn1.id) + self.assertEqual('excel-direct', conn1.connection_type) + self.assertEqual('', conn1.server_address) + self.assertEqual('', conn1.username) + self.assertEqual(False, conn1.embed_password) + self.assertEqual('b47f41b1-2c47-41a3-8b17-a38ebe8b340c', conn2.id) + self.assertEqual('sqlserver', conn2.connection_type) + self.assertEqual('test.database.com', conn2.server_address) + self.assertEqual('bob', conn2.username) + self.assertEqual(False, conn2.embed_password) + self.assertEqual('4f4a3b78-0554-43a7-b327-9605e9df9dd2', conn3.id) + self.assertEqual('tableau-server-site', conn3.connection_type) + self.assertEqual('http://tableauserver', conn3.server_address) + self.assertEqual('sally', conn3.username) + self.assertEqual(True, conn3.embed_password) + + def test_populate_permissions(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + single_datasource = TSC.FlowItem('test') + single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.flows.populate_permissions(single_datasource) + permissions = single_datasource.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, 'aa42f384-906f-11e9-86fc-bb24278874b9') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }) From 903fa97effba748bd71e9fcca042a609ad38102f Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 3 Sep 2019 13:51:14 -0700 Subject: [PATCH 101/567] Add topLevelProject filter to projects (#497) --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 9f3247e7b..7e1e6a808 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -34,6 +34,7 @@ class Field: Subtitle = 'subtitle' Tags = 'tags' Title = 'title' + TopLevelProject = 'topLevelProject' Type = 'type' UpdatedAt = 'updatedAt' UserCount = 'userCount' From d87eaa2b6ea4a7a53ec9469b5dcddaa5b79b9870 Mon Sep 17 00:00:00 2001 From: Francisco Pagliaricci Date: Tue, 10 Sep 2019 18:58:51 +0000 Subject: [PATCH 102/567] renamed PAT fields (#490) --- tableauserverclient/models/personal_access_token_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index 0bb9b2c02..c9b892cf6 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -8,4 +8,4 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): - return {'clientId': self.token_name, 'personalAccessToken': self.personal_access_token} + return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} From 234011b3995ef0f9b787c8d5e7073ac1350dae7d Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 12 Sep 2019 17:02:04 -0700 Subject: [PATCH 103/567] Add variable support to GraphQL Endpoint (#498) * Add variable support to GraphQL Endpoint * user public accessor --- tableauserverclient/server/endpoint/metadata_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 82f91844c..002379407 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -9,15 +9,15 @@ class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/exp/metadata/graphql".format(self.parent_srv._server_address) + return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) @api("3.2") - def query(self, query, abort_on_error=False): + def query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API') url = self.baseurl try: - graphql_query = json.dumps({'query': query}) + graphql_query = json.dumps({'query': query, 'variables': variables}) except Exception: # Place holder for now raise Exception('Must provide a string') From 533aeb1e8c0b243b3335c6e1ae39c10c4f3ed84f Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 4 Oct 2019 13:34:38 -0700 Subject: [PATCH 104/567] Changelog and contributors for v0.9 (#506) * Updates changelog and contributors for v0.9 --- CHANGELOG.md | 17 +++++++++++++++++ CONTRIBUTORS.md | 14 ++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421d577fd..266378f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.9 (4 Oct 2019) + +* Added Metadata API endpoints (#431) +* Added site settings for Data Catalog and Prep Conductor (#434) +* Added new fields to ViewItem (#331) +* Added support and samples for Tableau Server Personal Access Tokens (#465) +* Added Permissions endpoints (#429) +* Added tags to ViewItem (#470) +* Added Databases and Tables endpoints (#445) +* Added Flow endpoints (#494) +* Added ability to filter projects by topLevelProject attribute (#497) +* Improved server_info endpoint error handling (#439) +* Improved Pager to take in keyword arguments (#451) +* Fixed UUID serialization error while publishing workbook (#449) +* Fixed materalized views in request body for update_workbook (#461) + + ## 0.8 (8 Apr 2019) * Added Max Age to download view image request (#360) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bffde46c7..315373737 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -19,16 +19,22 @@ The following people have contributed to this project to make it possible, and w * [Bruce Zhang](https://github.com/baixin137) * [Bumsoo Kim](https://github.com/bskim45) * [daniel1608](https://github.com/daniel1608) +* [Joshua Jacob](https://github.com/jacobj10) +* [Francisco Pagliaricci](https://github.com/fpagliar) +* [Tomasz Machalski](https://github.com/toomyem) +* [Jared Dominguez](https://github.com/jdomingu) +* [Brendan Lee](https://github.com/lbrendanl) ## Core Team -* [Shin Chris](https://github.com/shinchris) +* [Chris Shin](https://github.com/shinchris) * [Lee Graber](https://github.com/lgraber) * [Tyler Doyle](https://github.com/t8y8) * [Russell Hay](https://github.com/RussTheAerialist) * [Ben Lower](https://github.com/benlower) -* [Jared Dominguez](https://github.com/jdomingu) * [Jackson Huang](https://github.com/jz-huang) -* [Brendan Lee](https://github.com/lbrendanl) * [Ang Gao](https://github.com/gaoang2148) -* [Priya R](https://github.com/preguraman) +* [Priya Reguraman](https://github.com/preguraman) +* [Jac Fitzgerald](https://github.com/jacalata) +* [Dan Zucker](https://github.com/dzucker-tab) +* [Irwin Dolobowsky](https://github.com/irwando) From fe3ac07d1219a74dc6ab24f4f9828c82d7d49177 Mon Sep 17 00:00:00 2001 From: shinchris Date: Fri, 4 Oct 2019 14:05:03 -0700 Subject: [PATCH 105/567] Fixes CI error and includes more contributors --- CONTRIBUTORS.md | 4 ++++ test/test_sort.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 315373737..8022c5f49 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -24,6 +24,10 @@ The following people have contributed to this project to make it possible, and w * [Tomasz Machalski](https://github.com/toomyem) * [Jared Dominguez](https://github.com/jdomingu) * [Brendan Lee](https://github.com/lbrendanl) +* [Martin Dertz](https://github.com/martydertz) +* [Christian Oliff](https://github.com/coliff) +* [Albin Antony](https://github.com/user9747) +* [prae04](https://github.com/prae04) ## Core Team diff --git a/test/test_sort.py b/test/test_sort.py index 40936d835..5eef07a9d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -57,7 +57,7 @@ def test_filter_in(self): request_object=opts, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:[stocks,market]') def test_sort_asc(self): with requests_mock.mock() as m: From ba2796f707455879eb54783553577d202ffb3042 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 24 Oct 2019 14:39:03 -0700 Subject: [PATCH 106/567] Quick Fix for NonXMLErrorException (#515) Catch ParseError's and pass the response body up though the stack. Partially addresses #514 --- tableauserverclient/server/endpoint/endpoint.py | 10 ++++++++-- test/test_requests.py | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8c7e93607..2b2bca229 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, InternalServerError +from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError from functools import wraps from xml.etree.ElementTree import ParseError @@ -69,8 +69,14 @@ def _check_status(self, server_response): try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) except ParseError: - # not an xml error + # This will happen if we get a non-success HTTP code that + # doesn't return an xml error object (like metadata endpoints) + # we convert this to a better exception and pass through the raw + # response body raise NonXMLResponseError(server_response.content) + except Exception as e: + # anything else re-raise here + raise def get_unauthenticated_request(self, url, request_object=None): return self._make_request(self.parent_srv.session.get, url, request_object=request_object) diff --git a/test/test_requests.py b/test/test_requests.py index 80216ec85..67282b6f9 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -5,7 +5,7 @@ import tableauserverclient as TSC -from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.exceptions import InternalServerError, NonXMLResponseError class RequestTests(unittest.TestCase): @@ -55,3 +55,11 @@ def test_internal_server_error(self): 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) + + # Test that non-xml server errors are handled properly + def test_non_xml_error(self): + self.server.version = "3.2" + 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) From 82de656cef635f9ce1669d20b7b52a3138a60f53 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 31 Oct 2019 14:30:43 -0700 Subject: [PATCH 107/567] Fix filename behavior and refactor (#517) Fixing a subtle file name bug and adding some new tests to cover filesystem helpers --- setup.py | 3 +- tableauserverclient/filesys_helpers.py | 16 ++++++++ .../server/endpoint/datasources_endpoint.py | 14 +++---- .../server/endpoint/flows_endpoint.py | 14 +++---- .../server/endpoint/workbooks_endpoint.py | 14 +++---- test/test_regression_tests.py | 40 +++++++++++++++++++ 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/setup.py b/setup.py index a7b29aa90..cea7260a0 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ ], tests_require=[ 'requests-mock>=1.0,<2.0', - 'pytest' + 'pytest', + 'mock' ] ) diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py index 0cf304b32..9d0b443bf 100644 --- a/tableauserverclient/filesys_helpers.py +++ b/tableauserverclient/filesys_helpers.py @@ -1,6 +1,22 @@ +import os ALLOWED_SPECIAL = (' ', '.', '_', '-') def to_filename(string_to_sanitize): sanitized = (c for c in string_to_sanitize if c.isalnum() or c in ALLOWED_SPECIAL) return "".join(sanitized) + + +def make_download_path(filepath, filename): + download_path = None + + if filepath is None: + download_path = filename + + elif os.path.isdir(filepath): + download_path = os.path.join(filepath, filename) + + else: + download_path = filepath + os.path.splitext(filename)[1] + + return download_path diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c46a7dc74..eef88d09e 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,7 @@ from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path from ...models.tag_item import TagItem from ...models.job_item import JobItem import os @@ -104,17 +104,15 @@ def download(self, datasource_id, filepath=None, include_extract=True, no_extrac with closing(self.get_request(url, parameters={'stream': True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded datasource to {0} (ID: {1})'.format(filepath, datasource_id)) - return os.path.abspath(filepath) + logger.info('Downloaded datasource to {0} (ID: {1})'.format(download_path, datasource_id)) + return os.path.abspath(download_path) # Update datasource @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index b2c616959..7bad807e4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -6,7 +6,7 @@ from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path from ...models.tag_item import TagItem from ...models.job_item import JobItem import os @@ -94,17 +94,15 @@ def download(self, flow_id, filepath=None): with closing(self.get_request(url, parameters={'stream': True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded flow to {0} (ID: {1})'.format(filepath, flow_id)) - return os.path.abspath(filepath) + logger.info('Downloaded flow to {0} (ID: {1})'.format(download_path, flow_id)) + return os.path.abspath(download_path) # Update flow @api(version="3.3") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 445b0ccde..3d81e3999 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -8,7 +8,7 @@ from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.tag_item import TagItem from ...models.job_item import JobItem -from ...filesys_helpers import to_filename +from ...filesys_helpers import to_filename, make_download_path import os import logging @@ -129,16 +129,14 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers['Content-Disposition']) filename = to_filename(os.path.basename(params['filename'])) - if filepath is None: - filepath = filename - elif os.path.isdir(filepath): - filepath = os.path.join(filepath, filename) - with open(filepath, 'wb') as f: + download_path = make_download_path(filepath, filename) + + with open(download_path, 'wb') as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded workbook to {0} (ID: {1})'.format(filepath, workbook_id)) - return os.path.abspath(filepath) + logger.info('Downloaded workbook to {0} (ID: {1})'.format(download_path, workbook_id)) + return os.path.abspath(download_path) # Get all views of workbook @api(version="2.0") diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 8958c3cf8..932e993de 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,6 +1,13 @@ import unittest + +try: + from unittest import mock +except ImportError: + import mock + import tableauserverclient.server.request_factory as factory from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.filesys_helpers import to_filename, make_download_path class BugFix257(unittest.TestCase): @@ -21,3 +28,36 @@ class FakeResponse(object): server_response = FakeResponse() self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') + + +class FileSysHelpers(unittest.TestCase): + def test_to_filename(self): + invalid = [ + "23brhafbjrjhkbbea.txt", + 'a_b_C.txt', + 'windows space.txt', + 'abc#def.txt', + 't@bL3A()', + ] + + valid = [ + "23brhafbjrjhkbbea.txt", + 'a_b_C.txt', + 'windows space.txt', + 'abcdef.txt', + 'tbL3A', + ] + + self.assertTrue(all([(to_filename(i) == v) for i, v in zip(invalid, valid)])) + + def test_make_download_path(self): + no_file_path = (None, 'file.ext') + 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)) + + 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)) From 808e5b9dedffe7221a1bd5ca45b75b35bf711bf0 Mon Sep 17 00:00:00 2001 From: Martin Peters Date: Mon, 4 Nov 2019 21:22:39 +0000 Subject: [PATCH 108/567] Runtime error in permissions_endpoint (#513) Replaced with grantee.tag_name in the permissions_endpoint delete method. Fixed indentation and type in default_permissions_endpoint.py method delete_default_permission --- .../endpoint/default_permissions_endpoint.py | 21 ++++++++++--------- .../server/endpoint/permissions_endpoint.py | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index f2e48db7a..9934ee176 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -38,16 +38,17 @@ def update_default_permissions(self, resource, permissions, content_type): def delete_default_permission(self, resource, rule, content_type): for capability, mode in rule.capabilities.items(): - # Made readibility better but line is too long, will make this look better - url = '{baseurl}/{content_id}/default-permissions/\ - {content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}'.format( - baseurl=self.owner_baseurl(), - content_id=resource.id, - content_type=content_type, - grantee_type=rule.grantee.tag_name + 's', - grantee_id=rule.grantee.id, - cap=capability, - mode=mode) + # Made readability better but line is too long, will make this look better + url = '{baseurl}/{content_id}/default-permissions/' \ + '{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}' \ + .format( + baseurl=self.owner_baseurl(), + content_id=resource.id, + content_type=content_type, + grantee_type=rule.grantee.tag_name + 's', + grantee_id=rule.grantee.id, + cap=capability, + mode=mode) logger.debug('Removing {0} permission for capabilty {1}'.format( mode, capability)) diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 6405f96a0..b28d6fa17 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -48,7 +48,7 @@ def delete(self, resource, rules): url = '{0}/{1}/permissions/{2}/{3}/{4}/{5}'.format( self.owner_baseurl(), resource.id, - rule.grantee.permissions_grantee_type + 's', + rule.grantee.tag_name + 's', rule.grantee.id, capability, mode) @@ -59,7 +59,7 @@ def delete(self, resource, rules): self.delete_request(url) logger.info('Deleted permission for {0} {1} item {2}'.format( - rule.grantee.permissions_grantee_type, + rule.grantee.tag_name, rule.grantee.id, resource.id)) From a6cc77d401b151d8e1eb896142f6819ccf0d4228 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 5 Nov 2019 10:58:16 -0800 Subject: [PATCH 109/567] delete docs folder from master (#520) * delete folder * add back readme for docs --- docs/Gemfile | 3 - docs/{docs/readme.md => README.md} | 0 docs/_config.yml | 17 - docs/_includes/analytics.html | 7 - docs/_includes/docs_menu.html | 73 --- docs/_includes/footer.html | 8 - docs/_includes/head.html | 18 - docs/_includes/header.html | 29 -- docs/_includes/icon-github.svg | 1 - docs/_includes/search_form.html | 7 - docs/_layouts/default.html | 34 -- docs/_layouts/docs.html | 31 -- docs/_layouts/home.html | 19 - docs/_layouts/search.html | 43 -- docs/assets/logo.png | Bin 2800 -> 0 bytes docs/css/api_ref.css | 709 ----------------------------- docs/css/extra.css | 14 - docs/css/github-highlight.css | 224 --------- docs/css/main.css | 276 ----------- docs/index.md | 13 - docs/js/lunr.min.js | 6 - docs/js/redirect-to-search.js | 13 - docs/js/search.js | 71 --- 23 files changed, 1616 deletions(-) delete mode 100644 docs/Gemfile rename docs/{docs/readme.md => README.md} (100%) delete mode 100644 docs/_config.yml delete mode 100644 docs/_includes/analytics.html delete mode 100644 docs/_includes/docs_menu.html delete mode 100644 docs/_includes/footer.html delete mode 100644 docs/_includes/head.html delete mode 100644 docs/_includes/header.html delete mode 100644 docs/_includes/icon-github.svg delete mode 100644 docs/_includes/search_form.html delete mode 100644 docs/_layouts/default.html delete mode 100644 docs/_layouts/docs.html delete mode 100644 docs/_layouts/home.html delete mode 100644 docs/_layouts/search.html delete mode 100644 docs/assets/logo.png delete mode 100644 docs/css/api_ref.css delete mode 100644 docs/css/extra.css delete mode 100644 docs/css/github-highlight.css delete mode 100644 docs/css/main.css delete mode 100644 docs/index.md delete mode 100644 docs/js/lunr.min.js delete mode 100644 docs/js/redirect-to-search.js delete mode 100644 docs/js/search.js diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index 775d954bf..000000000 --- a/docs/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' -gem 'github-pages', group: :jekyll_plugins - diff --git a/docs/docs/readme.md b/docs/README.md similarity index 100% rename from docs/docs/readme.md rename to docs/README.md diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index 5ea15f228..000000000 --- a/docs/_config.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Site settings -title: Tableau Server Client Library (Python) -email: github@tableau.com -description: Simplify interactions with the Tableau Server REST API. -baseurl: "/https/github.com/server-client-python" -permalinks: pretty -defaults: - - - scope: - path: "" # Apply to all files - values: - layout: "default" - -# Build settings -markdown: kramdown -highlighter: rouge - diff --git a/docs/_includes/analytics.html b/docs/_includes/analytics.html deleted file mode 100644 index 0cdbad25d..000000000 --- a/docs/_includes/analytics.html +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/docs/_includes/docs_menu.html b/docs/_includes/docs_menu.html deleted file mode 100644 index 104a1f5b3..000000000 --- a/docs/_includes/docs_menu.html +++ /dev/null @@ -1,73 +0,0 @@ -
- {% include search_form.html %} - -
diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html deleted file mode 100644 index 486c81d22..000000000 --- a/docs/_includes/footer.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
- -

This site is open source. Suggestions and pull requests are welcome on our GitHub page.

-

© 2016 Tableau.

-
-
diff --git a/docs/_includes/head.html b/docs/_includes/head.html deleted file mode 100644 index 083e3f268..000000000 --- a/docs/_includes/head.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} - - - - - - - - - - - - -{% if jekyll.environment == "production" %}{% include analytics.html %}{% endif %} diff --git a/docs/_includes/header.html b/docs/_includes/header.html deleted file mode 100644 index 106578dfc..000000000 --- a/docs/_includes/header.html +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/docs/_includes/icon-github.svg b/docs/_includes/icon-github.svg deleted file mode 100644 index 4422c4f5d..000000000 --- a/docs/_includes/icon-github.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_includes/search_form.html b/docs/_includes/search_form.html deleted file mode 100644 index 41bb34259..000000000 --- a/docs/_includes/search_form.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
- diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index 38ee020bb..000000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - {% include head.html %} - - - -
- {% include header.html %} -
    - {% for post in site.posts %} -
    -

    {{ post.title }}

    -
    -

    Posted on {{ post.date | date: "%-d %B %Y" }}

    -
    -

    - {{ post.abstract }} -

    - {% if post.photoname %} - {% endif %} -
    -
    - {{ post.content }} -
    -
    - {% endfor %} -
- {% include footer.html %} -
- - - diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html deleted file mode 100644 index 5355f63df..000000000 --- a/docs/_layouts/docs.html +++ /dev/null @@ -1,31 +0,0 @@ ---- -layout: docs ---- - - - - - - {% include head.html %} - - - -
- {% include header.html %} - {% include docs_menu.html %} - -
-

{{ page.title }}

- -
- {{ content }} - {% include footer.html %} -
-
- - - diff --git a/docs/_layouts/home.html b/docs/_layouts/home.html deleted file mode 100644 index c2cf32fcb..000000000 --- a/docs/_layouts/home.html +++ /dev/null @@ -1,19 +0,0 @@ ---- -layout: home ---- - - - - - {% include head.html %} - - - -
- {% include header.html %} - {{ content }} - {% include footer.html %} -
- - - diff --git a/docs/_layouts/search.html b/docs/_layouts/search.html deleted file mode 100644 index 96dbd94a1..000000000 --- a/docs/_layouts/search.html +++ /dev/null @@ -1,43 +0,0 @@ ---- -layout: search ---- - - - - - - {% include head.html %} - - - - - - - -
- {% include header.html %} - {% include docs_menu.html %} - -
-

-
-
-

Loading search results...

-
- - {% include footer.html %} -
-
- - diff --git a/docs/assets/logo.png b/docs/assets/logo.png deleted file mode 100644 index 60761152152291896e7b27f94d981fc82e71a2dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2800 zcmb7GdpuNWA3rk7jj(zc zA_9#@6Cwf+j{N!tppenAD|!*Tt? zar#(2+n89aH%lSV-lRb3+Ff~Q<4W3MbT z@VqTFFgsTxdBECEUTw_=QFxAOl%7~&W%r{p6`lSZYEWfw_N(j+_voCtm@(sQg^ZaR zsIPw<-y$EgstQv?0+7GAo#~0Ybf<5&_#AxMFXwb?)9ujtx3<&f(hsV490=3nB^pAJ zMTU9sKs7)h8oMvf0Qh2Q4X6li#W2BNGXt$=HfdHSpi}0iurAQrwHK3}3Gfe>Z7BX< zq3$f|V$_05hx;%OITN-*535$zVg^snD5$=sNBEW(^)1Yyj%k;vy{wVRpW9#M8}ico z`xoM42SX)!goCAfAkAyd301SblpHR7C55iE*E9iL;}XNM-OoAN8fZ={l9N%*^3B|5 z+V!G$`?J+2lS2Zi$d%7H)4u(#VPVgB&d*}qPUgG#ThLM3Ie<@F!WLyAXzZ~#FYjD+ zIcvcy&*fuR^=SCM2%}Q-v zc6@V!Cjpy)p^eN{4k)7%h1+@LE#?q-XV*rWJ!rK@qaIQK(v4YOFAB7x9QfDe6NM9T z-$s!EQY1{y|L>u^zOJag>xEZBY%Z{hcR3Wh0HoqajIrx=-0Sw7LfNocy;ckD zlN=YnmJy+F`L(I;)F4-+U7OKAJsbV3r2%VZ}eDU0r44YGzvYI}6J8SJ|guC1AP%`f-qACE3fV`|%cn zrm1{UtI64mKbG$DOOHzKkquq?rE8foh%;9Zm5nB+dL)iK+JaZ!U~6g!!hIxBnI&b# z$l_tno&l91PI7OBh3n}-#xAoh>oCc6XWvxt`wJTuf^d(s<}9GdK6Q<@;aQV|ORNy{ zSCSp6KuQ-%D(pXbx-1S)|y2kxwzf%4+Yp1u28EW3orO>jQfZQH*0SGO#V6wj~LH z<)E0yt_=N*&R#lMQ)g8PJQE`8ISv+%*X0_3T9W3rIcWicZqKG%Yk(AR--d?gIh72) z2r+UimAg<>@mA|l^qZc=n4@o@>6*ej!eE54DU+`cUpewrq~Ax-pgA#OG0qwL6UKbl z=eL@fTZ+?e-#8TA!rJd7GLVpNwr!iz;6k09xMrmF4 zt`Smbk^5c}plr=9^O)~g_!J7(`n47)Ylp(22O5Cjh!n;$4dB}~!?dtq_0Gs8N3eQ1 zP17Ubq4G{@a|0!xqt}$?3}}UEp`@G7$sOPMYInvg^IFEIqhj{zM)b;clX8_^($FpV zt<%~0;HRkUMv~TXfd4S@#1#${xvfNjuoX5p@EfZjDDz}~u|v7Nrpl4=zes(cc3LCn9^5yHcG4y0kRne# ztY1!+o3y&qA zttx95Z>yADY4Q91Ulv7;c6mO(mRHs9)^Np*XLpaxze8FIHcYEz0#evG5M-$;1_?3( za|j;>1-mGX;#mPCHLIkrNa>*{o6z_CiqqjK0lkSx&FzkS7Yk+B-62H&(gnE395AbQp4>LZYT`AK6 z;mN3uerOU4Zk;)^D5Po7bTw{9&P-|lK+$M(zbm&!Xc)N6RXFL6Tkw02sw!d+QCar@ z5bLMn&_+=8JJX;%;!;wr(0~AiZc26oNF#=17!(BX|MH;|H80s{HcH;Y$85@-7/*! normalize.css v1.1.3 | MIT License | git.io/normalize */ -/* 2 */ -/* ========================================================================== Tables ========================================================================== */ -/** Remove most spacing between table cells. */ -table { border-collapse: collapse; border-spacing: 0; } - -/* Visual Studio-like style based on original C# coloring by Jason Diamond */ -.hljs { display: inline-block; padding: 0.5em; background: white; color: black; } - -.hljs-comment, .hljs-annotation, .hljs-template_comment, .diff .hljs-header, .hljs-chunk, .apache .hljs-cbracket { color: #008000; } - -.hljs-keyword, .hljs-id, .hljs-built_in, .css .smalltalk .hljs-class, .hljs-winutils, .bash .hljs-variable, .tex .hljs-command, .hljs-request, .hljs-status, .nginx .hljs-title { color: #00f; } - -.xml .hljs-tag { color: #00f; } -.xml .hljs-tag .hljs-value { color: #00f; } - -.hljs-string, .hljs-title, .hljs-parent, .hljs-tag .hljs-value, .hljs-rules .hljs-value { color: #a31515; } - -.ruby .hljs-symbol { color: #a31515; } -.ruby .hljs-symbol .hljs-string { color: #a31515; } - -.hljs-template_tag, .django .hljs-variable, .hljs-addition, .hljs-flow, .hljs-stream, .apache .hljs-tag, .hljs-date, .tex .hljs-formula, .coffeescript .hljs-attribute { color: #a31515; } - -.ruby .hljs-string, .hljs-decorator, .hljs-filter .hljs-argument, .hljs-localvars, .hljs-array, .hljs-attr_selector, .hljs-pseudo, .hljs-pi, .hljs-doctype, .hljs-deletion, .hljs-envvar, .hljs-shebang, .hljs-preprocessor, .hljs-pragma, .userType, .apache .hljs-sqbracket, .nginx .hljs-built_in, .tex .hljs-special, .hljs-prompt { color: #2b91af; } - -.hljs-phpdoc, .hljs-javadoc, .hljs-xmlDocTag { color: #808080; } - -.vhdl .hljs-typename { font-weight: bold; } -.vhdl .hljs-string { color: #666666; } -.vhdl .hljs-literal { color: #a31515; } -.vhdl .hljs-attribute { color: #00b0e8; } - -.xml .hljs-attribute { color: #f00; } - -.col > :first-child, .col-1 > :first-child, .col-2 > :first-child, .col-3 > :first-child, .col-4 > :first-child, .col-5 > :first-child, .col-6 > :first-child, .col-7 > :first-child, .col-8 > :first-child, .col-9 > :first-child, .col-10 > :first-child, .col-11 > :first-child, .tsd-panel > :first-child, ul.tsd-descriptions > li > :first-child, .col > :first-child > :first-child, .col-1 > :first-child > :first-child, .col-2 > :first-child > :first-child, .col-3 > :first-child > :first-child, .col-4 > :first-child > :first-child, .col-5 > :first-child > :first-child, .col-6 > :first-child > :first-child, .col-7 > :first-child > :first-child, .col-8 > :first-child > :first-child, .col-9 > :first-child > :first-child, .col-10 > :first-child > :first-child, .col-11 > :first-child > :first-child, .tsd-panel > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child, .col > :first-child > :first-child > :first-child, .col-1 > :first-child > :first-child > :first-child, .col-2 > :first-child > :first-child > :first-child, .col-3 > :first-child > :first-child > :first-child, .col-4 > :first-child > :first-child > :first-child, .col-5 > :first-child > :first-child > :first-child, .col-6 > :first-child > :first-child > :first-child, .col-7 > :first-child > :first-child > :first-child, .col-8 > :first-child > :first-child > :first-child, .col-9 > :first-child > :first-child > :first-child, .col-10 > :first-child > :first-child > :first-child, .col-11 > :first-child > :first-child > :first-child, .tsd-panel > :first-child > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child > :first-child { margin-top: 0; } -.col > :last-child, .col-1 > :last-child, .col-2 > :last-child, .col-3 > :last-child, .col-4 > :last-child, .col-5 > :last-child, .col-6 > :last-child, .col-7 > :last-child, .col-8 > :last-child, .col-9 > :last-child, .col-10 > :last-child, .col-11 > :last-child, .tsd-panel > :last-child, ul.tsd-descriptions > li > :last-child, .col > :last-child > :last-child, .col-1 > :last-child > :last-child, .col-2 > :last-child > :last-child, .col-3 > :last-child > :last-child, .col-4 > :last-child > :last-child, .col-5 > :last-child > :last-child, .col-6 > :last-child > :last-child, .col-7 > :last-child > :last-child, .col-8 > :last-child > :last-child, .col-9 > :last-child > :last-child, .col-10 > :last-child > :last-child, .col-11 > :last-child > :last-child, .tsd-panel > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child, .col > :last-child > :last-child > :last-child, .col-1 > :last-child > :last-child > :last-child, .col-2 > :last-child > :last-child > :last-child, .col-3 > :last-child > :last-child > :last-child, .col-4 > :last-child > :last-child > :last-child, .col-5 > :last-child > :last-child > :last-child, .col-6 > :last-child > :last-child > :last-child, .col-7 > :last-child > :last-child > :last-child, .col-8 > :last-child > :last-child > :last-child, .col-9 > :last-child > :last-child > :last-child, .col-10 > :last-child > :last-child > :last-child, .col-11 > :last-child > :last-child > :last-child, .tsd-panel > :last-child > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child > :last-child { margin-bottom: 0; } - -@media (max-width: 640px) { .container { padding: 0 20px; } } - -.container-main { padding-bottom: 200px; } - -.row { position: relative; margin: 0 -10px; } -.row:after { visibility: hidden; display: block; content: ""; clear: both; height: 0; } - -.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11 { box-sizing: border-box; float: left; padding: 0 10px; } - -.col-1 { width: 8.33333%; } - -.offset-1 { margin-left: 8.33333%; } - -.col-2 { width: 16.66667%; } - -.offset-2 { margin-left: 16.66667%; } - -.col-3 { width: 25%; } - -.offset-3 { margin-left: 25%; } - -.col-4 { width: 33.33333%; } - -.offset-4 { margin-left: 33.33333%; } - -.col-5 { width: 41.66667%; } - -.offset-5 { margin-left: 41.66667%; } - -.col-6 { width: 50%; } - -.offset-6 { margin-left: 50%; } - -.col-7 { width: 58.33333%; } - -.offset-7 { margin-left: 58.33333%; } - -.col-8 { width: 66.66667%; } - -.offset-8 { margin-left: 66.66667%; } - -.col-9 { width: 75%; } - -.offset-9 { margin-left: 75%; } - -.col-10 { width: 83.33333%; } - -.offset-10 { margin-left: 83.33333%; } - -.col-11 { width: 91.66667%; } - -.offset-11 { margin-left: 91.66667%; } - -.tsd-kind-icon { display: block; position: relative; padding-left: 20px; text-indent: -20px; } -.tsd-kind-icon:before { content: ''; display: inline-block; vertical-align: middle; width: 17px; height: 17px; margin: 0 3px 2px 0; background-image: url(data:image/png;base64,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); } -@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-kind-icon:before { background-image: url(data:image/png;base64,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); background-size: 238px 204px; } } - -.tsd-signature.tsd-kind-icon:before { background-position: 0 -153px; } - -.tsd-kind-object-literal > .tsd-kind-icon:before { background-position: 0px -17px; } -.tsd-kind-object-literal.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -17px; } -.tsd-kind-object-literal.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -17px; } - -.tsd-kind-class > .tsd-kind-icon:before { background-position: 0px -34px; } -.tsd-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -34px; } -.tsd-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -34px; } - -.tsd-kind-class.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -51px; } -.tsd-kind-class.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -51px; } -.tsd-kind-class.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -51px; } - -.tsd-kind-interface > .tsd-kind-icon:before { background-position: 0px -68px; } -.tsd-kind-interface.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -68px; } -.tsd-kind-interface.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -68px; } - -.tsd-kind-interface.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -85px; } -.tsd-kind-interface.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -85px; } -.tsd-kind-interface.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -85px; } - -.tsd-kind-module > .tsd-kind-icon:before { background-position: 0px -102px; } -.tsd-kind-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } -.tsd-kind-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } - -.tsd-kind-external-module > .tsd-kind-icon:before { background-position: 0px -102px; } -.tsd-kind-external-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } -.tsd-kind-external-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } - -.tsd-kind-enum > .tsd-kind-icon:before { background-position: 0px -119px; } -.tsd-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -119px; } -.tsd-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -119px; } - -.tsd-kind-enum-member > .tsd-kind-icon:before { background-position: 0px -136px; } -.tsd-kind-enum-member.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -136px; } -.tsd-kind-enum-member.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -136px; } - -.tsd-kind-signature > .tsd-kind-icon:before { background-position: 0px -153px; } -.tsd-kind-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -153px; } -.tsd-kind-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -153px; } - -.tsd-kind-type-alias > .tsd-kind-icon:before { background-position: 0px -170px; } -.tsd-kind-type-alias.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -170px; } -.tsd-kind-type-alias.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -170px; } - -.tsd-kind-variable > .tsd-kind-icon:before { background-position: -136px -0px; } -.tsd-kind-variable.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } -.tsd-kind-variable.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } -.tsd-kind-variable.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } - -.tsd-kind-property > .tsd-kind-icon:before { background-position: -136px -0px; } -.tsd-kind-property.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } -.tsd-kind-property.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } -.tsd-kind-property.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } -.tsd-kind-property.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } -.tsd-kind-property.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } - -.tsd-kind-get-signature > .tsd-kind-icon:before { background-position: -136px -17px; } -.tsd-kind-get-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -17px; } -.tsd-kind-get-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -17px; } - -.tsd-kind-set-signature > .tsd-kind-icon:before { background-position: -136px -34px; } -.tsd-kind-set-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -34px; } -.tsd-kind-set-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -34px; } - -.tsd-kind-accessor > .tsd-kind-icon:before { background-position: -136px -51px; } -.tsd-kind-accessor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -51px; } -.tsd-kind-accessor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -51px; } -.tsd-kind-accessor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -51px; } - -.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-function.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } - -.tsd-kind-method.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } - -.tsd-kind-constructor > .tsd-kind-icon:before { background-position: -136px -102px; } -.tsd-kind-constructor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } -.tsd-kind-constructor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } -.tsd-kind-constructor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } - -.tsd-kind-constructor-signature > .tsd-kind-icon:before { background-position: -136px -102px; } -.tsd-kind-constructor-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } -.tsd-kind-constructor-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } - -.tsd-kind-index-signature > .tsd-kind-icon:before { background-position: -136px -119px; } -.tsd-kind-index-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -119px; } -.tsd-kind-index-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -119px; } - -.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -136px; } -.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -136px; } -.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -136px; } -.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -136px; } -.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -136px; } -.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -136px; } - -.tsd-is-static > .tsd-kind-icon:before { background-position: -136px -153px; } -.tsd-is-static.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -153px; } -.tsd-is-static.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -153px; } -.tsd-is-static.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -153px; } -.tsd-is-static.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -153px; } -.tsd-is-static.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -153px; } - -.tsd-is-static.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -187px; } -.tsd-is-static.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -187px; } -.tsd-is-static.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -187px; } - -.no-transition { -webkit-transition: none !important; transition: none !important; } - -@-webkit-keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } } - -@keyframes fade-in { from { opacity: 0; } - to { opacity: 1; } } -@-webkit-keyframes fade-out { - from { opacity: 1; visibility: visible; } - to { opacity: 0; } } -@keyframes fade-out { from { opacity: 1; visibility: visible; } - to { opacity: 0; } } -@-webkit-keyframes fade-in-delayed { - 0% { opacity: 0; } - 33% { opacity: 0; } - 100% { opacity: 1; } } -@keyframes fade-in-delayed { 0% { opacity: 0; } - 33% { opacity: 0; } - 100% { opacity: 1; } } -@-webkit-keyframes fade-out-delayed { - 0% { opacity: 1; visibility: visible; } - 66% { opacity: 0; } - 100% { opacity: 0; } } -@keyframes fade-out-delayed { 0% { opacity: 1; visibility: visible; } - 66% { opacity: 0; } - 100% { opacity: 0; } } -@-webkit-keyframes shift-to-left { - from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } - to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } -@keyframes shift-to-left { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } - to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } -@-webkit-keyframes unshift-to-left { - from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@keyframes unshift-to-left { from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@-webkit-keyframes pop-in-from-right { - from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@keyframes pop-in-from-right { from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@-webkit-keyframes pop-out-to-right { - from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } - to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } -@keyframes pop-out-to-right { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } - to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } - -.tsd-typography { line-height: 1.333em; } -.tsd-typography ul { list-style: square; padding: 0 0 0 20px; margin: 0; } -.tsd-typography h4, .tsd-typography .tsd-index-panel h3, .tsd-index-panel .tsd-typography h3, .tsd-typography h5, .tsd-typography h6 { font-size: 1em; margin: 0; } -.tsd-typography h5, .tsd-typography h6 { font-weight: normal; } -.tsd-typography p, .tsd-typography ul, .tsd-typography ol { margin: 1em 0; } - -@media (min-width: 901px) and (max-width: 1024px) { html.default .col-content { width: 72%; } - html.default .col-menu { width: 28%; } - html.default .tsd-navigation { padding-left: 10px; } } -@media (max-width: 900px) { html.default .col-content { float: none; width: 100%; } - html.default .col-menu { position: fixed !important; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; z-index: 1024; top: 0 !important; bottom: 0 !important; left: auto !important; right: 0 !important; width: 100%; padding: 20px 20px 0 0; max-width: 450px; visibility: hidden; background-color: #fff; -webkit-transform: translate(100%, 0); -ms-transform: translate(100%, 0); transform: translate(100%, 0); } - html.default .col-menu > *:last-child { padding-bottom: 20px; } - html.default .overlay { content: ""; display: block; position: fixed; z-index: 1023; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.75); visibility: hidden; } - html.default.to-has-menu .overlay { -webkit-animation: fade-in 0.4s; animation: fade-in 0.4s; } - html.default.to-has-menu header, html.default.to-has-menu footer, html.default.to-has-menu .col-content { -webkit-animation: shift-to-left 0.4s; animation: shift-to-left 0.4s; } - html.default.to-has-menu .col-menu { -webkit-animation: pop-in-from-right 0.4s; animation: pop-in-from-right 0.4s; } - html.default.from-has-menu .overlay { -webkit-animation: fade-out 0.4s; animation: fade-out 0.4s; } - html.default.from-has-menu header, html.default.from-has-menu footer, html.default.from-has-menu .col-content { -webkit-animation: unshift-to-left 0.4s; animation: unshift-to-left 0.4s; } - html.default.from-has-menu .col-menu { -webkit-animation: pop-out-to-right 0.4s; animation: pop-out-to-right 0.4s; } - html.default.has-menu body { overflow: hidden; } - html.default.has-menu .overlay { visibility: visible; } - html.default.has-menu header, html.default.has-menu footer, html.default.has-menu .col-content { -webkit-transform: translate(-25%, 0); -ms-transform: translate(-25%, 0); transform: translate(-25%, 0); } - html.default.has-menu .col-menu { visibility: visible; -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0); } } - -.tsd-page-title { padding: 70px 0 20px 0; margin: 0 0 40px 0; background: #fff; box-shadow: 0 0 5px rgba(0, 0, 0, 0.35); } -.tsd-page-title h1 { margin: 0; } - -.tsd-breadcrumb { margin: 0; padding: 0; color: #808080; } -.tsd-breadcrumb a { color: #808080; text-decoration: none; } -.tsd-breadcrumb a:hover { text-decoration: underline; } -.tsd-breadcrumb li { display: inline; } -.tsd-breadcrumb li:after { content: " / "; } - -html.minimal .container-main { padding-bottom: 0; } -html.minimal .content-wrap { padding-left: 340px; } -html.minimal .tsd-navigation { position: fixed !important; float: left; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; box-sizing: border-box; z-index: 1; top: 60px; bottom: 0; width: 300px; padding: 20px; margin: 0; } -html.minimal .tsd-member .tsd-member { margin-left: 0; } -html.minimal .tsd-page-toolbar { position: fixed; z-index: 2; } -html.minimal #tsd-filter .tsd-filter-group { right: 0; -webkit-transform: none; -ms-transform: none; transform: none; } -html.minimal footer { background-color: transparent; } -html.minimal footer .container { padding: 0; } -html.minimal .tsd-generator { padding: 0; } -@media (max-width: 900px) { html.minimal .tsd-navigation { display: none; } - html.minimal .content-wrap { padding-left: 0; } } - -dl.tsd-comment-tags { overflow: hidden; } -dl.tsd-comment-tags dt { clear: both; float: left; padding: 1px 5px; margin: 0 10px 0 0; border-radius: 4px; border: 1px solid #808080; color: #808080; font-size: 0.8em; font-weight: normal; } -dl.tsd-comment-tags dd { margin: 0 0 10px 0; } -dl.tsd-comment-tags p { margin: 0; } - -.tsd-panel.tsd-comment .lead { font-size: 1.1em; line-height: 1.333em; margin-bottom: 2em; } -.tsd-panel.tsd-comment .lead:last-child { margin-bottom: 0; } - -.toggle-protected .tsd-is-private { display: none; } - -.toggle-public .tsd-is-private, .toggle-public .tsd-is-protected, .toggle-public .tsd-is-private-protected { display: none; } - -.toggle-inherited .tsd-is-inherited { display: none; } - -.toggle-only-exported .tsd-is-not-exported { display: none; } - -.toggle-externals .tsd-is-external { display: none; } - -#tsd-filter { position: relative; display: inline-block; height: 40px; vertical-align: bottom; } -.no-filter #tsd-filter { display: none; } -#tsd-filter .tsd-filter-group { display: inline-block; height: 40px; vertical-align: bottom; white-space: nowrap; } -#tsd-filter input { display: none; } -@media (max-width: 900px) { #tsd-filter .tsd-filter-group { display: block; position: absolute; top: 40px; right: 20px; height: auto; background-color: #fff; visibility: hidden; -webkit-transform: translate(50%, 0); -ms-transform: translate(50%, 0); transform: translate(50%, 0); box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } - .has-options #tsd-filter .tsd-filter-group { visibility: visible; } - .to-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-in 0.2s; animation: fade-in 0.2s; } - .from-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-out 0.2s; animation: fade-out 0.2s; } - #tsd-filter label, #tsd-filter .tsd-select { display: block; padding-right: 20px; } } - -footer { background-color: #fff; } -footer.with-border-bottom { border-bottom: 1px solid #eee; margin-left: 20px } -footer .tsd-legend-group { font-size: 0; } -footer .tsd-legend { display: inline-block; width: 25%; padding: 0; font-size: 16px; list-style: none; line-height: 1.333em; vertical-align: top; } -@media (max-width: 900px) { footer .tsd-legend { width: 50%; } } - -.tsd-hierarchy { list-style: square; padding: 0 0 0 20px; margin: 0; } -.tsd-hierarchy .target { font-weight: bold; } - -.tsd-index-panel .tsd-index-content { margin-bottom: -30px !important; } -.tsd-index-panel .tsd-index-section { margin-bottom: 30px !important; } -.tsd-index-panel h3 { margin: 0 -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } -.tsd-index-panel ul.tsd-index-list { -webkit-column-count: 3; -moz-column-count: 3; -ms-column-count: 3; column-count: 3; -webkit-column-gap: 20px; -moz-column-gap: 20px; -ms-column-gap: 20px; column-gap: 20px; padding: 0; list-style: none; line-height: 1.333em; } -@media (max-width: 900px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 1; -moz-column-count: 1; -ms-column-count: 1; column-count: 1; } } -@media (min-width: 901px) and (max-width: 1024px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 2; -moz-column-count: 2; -ms-column-count: 2; column-count: 2; } } -.tsd-index-panel ul.tsd-index-list li { -webkit-column-break-inside: avoid; -moz-column-break-inside: avoid; -ms-column-break-inside: avoid; -o-column-break-inside: avoid; column-break-inside: avoid; -webkit-page-break-inside: avoid; -moz-page-break-inside: avoid; -ms-page-break-inside: avoid; -o-page-break-inside: avoid; page-break-inside: avoid; } -.tsd-index-panel a, .tsd-index-panel .tsd-parent-kind-module a { color: #9600ff; } -.tsd-index-panel .tsd-parent-kind-interface a { color: #7da01f; } -.tsd-index-panel .tsd-parent-kind-enum a { color: #cc9900; } -.tsd-index-panel .tsd-parent-kind-class a { color: #4da6ff; } -.tsd-index-panel .tsd-kind-module a { color: #9600ff; } -.tsd-index-panel .tsd-kind-interface a { color: #7da01f; } -.tsd-index-panel .tsd-kind-enum a { color: #cc9900; } -.tsd-index-panel .tsd-kind-class a { color: #4da6ff; } -.tsd-index-panel .tsd-is-private a { color: #808080; } - -.tsd-flag { display: inline-block; padding: 1px 5px; border-radius: 4px; color: #fff; background-color: #808080; text-indent: 0; font-size: 14px; font-weight: normal; } - -.tsd-anchor { position: absolute; top: -100px; } - -.tsd-member { position: relative; } -.tsd-member .tsd-anchor + h3 { margin-top: 0; margin-bottom: 0; border-bottom: none; } - -.tsd-navigation { padding: 0 0 0 40px; } -.tsd-navigation a { display: block; padding-top: 2px; padding-bottom: 2px; border-left: 2px solid transparent; color: #222; text-decoration: none; -webkit-transition: border-left-color 0.1s; transition: border-left-color 0.1s; } -.tsd-navigation a:hover { text-decoration: underline; } -.tsd-navigation ul { margin: 0; padding: 0; list-style: none; } -.tsd-navigation li { padding: 0; } - -.tsd-navigation.primary { padding-bottom: 40px; } -.tsd-navigation.primary a { display: block; padding-top: 6px; padding-bottom: 6px; } -.tsd-navigation.primary ul li a { padding-left: 5px; } -.tsd-navigation.primary ul li li a { padding-left: 25px; } -.tsd-navigation.primary ul li li li a { padding-left: 45px; } -.tsd-navigation.primary ul li li li li a { padding-left: 65px; } -.tsd-navigation.primary ul li li li li li a { padding-left: 85px; } -.tsd-navigation.primary ul li li li li li li a { padding-left: 105px; } -.tsd-navigation.primary > ul { border-bottom: 1px solid #eee; } -.tsd-navigation.primary li { border-top: 1px solid #eee; } -.tsd-navigation.primary li.current > a { font-weight: bold; } -.tsd-navigation.primary li.label span { display: block; padding: 20px 0 6px 5px; color: #808080; } -.tsd-navigation.primary li.globals + li > span, .tsd-navigation.primary li.globals + li > a { padding-top: 20px; } - -.tsd-navigation.secondary ul { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -.tsd-navigation.secondary ul li a { padding-left: 25px; } -.tsd-navigation.secondary ul li li a { padding-left: 45px; } -.tsd-navigation.secondary ul li li li a { padding-left: 65px; } -.tsd-navigation.secondary ul li li li li a { padding-left: 85px; } -.tsd-navigation.secondary ul li li li li li a { padding-left: 105px; } -.tsd-navigation.secondary ul li li li li li li a { padding-left: 125px; } -.tsd-navigation.secondary ul.current a { border-left-color: #eee; } -.tsd-navigation.secondary li.focus > a, .tsd-navigation.secondary ul.current li.focus > a { border-left-color: #000; } -.tsd-navigation.secondary li.current { margin-top: 20px; margin-bottom: 20px; border-left-color: #eee; } -.tsd-navigation.secondary li.current > a { font-weight: bold; } - -@media (min-width: 901px) { .menu-sticky-wrap { position: static; } - .no-csspositionsticky .menu-sticky-wrap.sticky { position: fixed; } - .no-csspositionsticky .menu-sticky-wrap.sticky-current { position: fixed; } - .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.before-current, .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.after-current { opacity: 0; } - .no-csspositionsticky .menu-sticky-wrap.sticky-bottom { position: absolute; top: auto !important; left: auto !important; bottom: 0; right: 0; } - .csspositionsticky .menu-sticky-wrap.sticky { position: -webkit-sticky; position: sticky; } - .csspositionsticky .menu-sticky-wrap.sticky-current { position: -webkit-sticky; position: sticky; } } - -.tsd-panel { margin: 20px 0; padding: 20px; background-color: #fff; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } -.tsd-panel:empty { display: none; } -.tsd-panel > h1, .tsd-panel > h2, .tsd-panel > h3 { margin: 1.5em -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } -.tsd-panel > h1.tsd-before-signature, .tsd-panel > h2.tsd-before-signature, .tsd-panel > h3.tsd-before-signature { margin-bottom: 0; border-bottom: 0; } -.tsd-panel table { display: block; width: 100%; overflow: auto; margin-top: 10px; word-break: normal; word-break: keep-all; } -.tsd-panel table th { font-weight: bold; } -.tsd-panel table th, .tsd-panel table td { padding: 6px 13px; border: 1px solid #ddd; } -.tsd-panel table tr { background-color: #fff; border-top: 1px solid #ccc; } -.tsd-panel table tr:nth-child(2n) { background-color: #f8f8f8; } - -.tsd-panel-group { margin: 30px 0; } -.tsd-panel-group > h1, .tsd-panel-group > h2, .tsd-panel-group > h3 { padding-left: 20px; padding-right: 20px; } - -#tsd-search { -webkit-transition: background-color 0.2s; transition: background-color 0.2s; } -#tsd-search .title { position: relative; z-index: 2; } -#tsd-search .field { position: absolute; left: 0; top: 0; right: 40px; height: 40px; } -#tsd-search .field input { box-sizing: border-box; position: relative; top: -50px; z-index: 1; width: 100%; padding: 0 10px; opacity: 0; outline: 0; border: 0; background: transparent; color: #222; } -#tsd-search .field label { position: absolute; overflow: hidden; right: -40px; } -#tsd-search .field input, #tsd-search .title { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -#tsd-search .results { position: absolute; visibility: hidden; top: 40px; width: 100%; margin: 0; padding: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } -#tsd-search .results li { padding: 0 10px; background-color: #fdfdfd; } -#tsd-search .results li:nth-child(even) { background-color: #fff; } -#tsd-search .results li.state { display: none; } -#tsd-search .results li.current, #tsd-search .results li:hover { background-color: #eee; } -#tsd-search .results a { display: block; } -#tsd-search .results a:before { top: 10px; } -#tsd-search .results span.parent { color: #808080; font-weight: normal; } -#tsd-search.has-focus { background-color: #eee; } -#tsd-search.has-focus .field input { top: 0; opacity: 1; } -#tsd-search.has-focus .title { z-index: 0; opacity: 0; } -#tsd-search.has-focus .results { visibility: visible; } -#tsd-search.loading .results li.state.loading { display: block; } -#tsd-search.failure .results li.state.failure { display: block; } - -.tsd-signature { margin: 0 0 1em 0; padding: 10px; border: 1px solid #eee; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; font-size: 14px; } -.tsd-signature.tsd-kind-icon { padding-left: 30px; } -.tsd-signature.tsd-kind-icon:before { top: 10px; left: 10px; } -.tsd-panel > .tsd-signature { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } -.tsd-panel > .tsd-signature.tsd-kind-icon { padding-left: 40px; } -.tsd-panel > .tsd-signature.tsd-kind-icon:before { left: 20px; } - -.tsd-signature-symbol { color: #808080; font-weight: normal; } - -.tsd-signature-type { font-style: italic; font-weight: normal; } - -.tsd-signatures { padding: 0; margin: 0 0 1em 0; border: 1px solid #eee; } -.tsd-signatures .tsd-signature { margin: 0; border-width: 1px 0 0 0; -webkit-transition: background-color 0.1s; transition: background-color 0.1s; } -.tsd-signatures .tsd-signature:first-child { border-top-width: 0; } -.tsd-signatures .tsd-signature.current { background-color: #eee; } -.tsd-signatures.active > .tsd-signature { cursor: pointer; } -.tsd-panel > .tsd-signatures { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } -.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon { padding-left: 40px; } -.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon:before { left: 20px; } -.tsd-panel > a.anchor + .tsd-signatures { border-top-width: 0; margin-top: -20px; } - -ul.tsd-descriptions { position: relative; overflow: hidden; -webkit-transition: height 0.3s; transition: height 0.3s; padding: 0; list-style: none; } -ul.tsd-descriptions.active > .tsd-description { display: none; } -ul.tsd-descriptions.active > .tsd-description.current { display: block; } -ul.tsd-descriptions.active > .tsd-description.fade-in { -webkit-animation: fade-in-delayed 0.3s; animation: fade-in-delayed 0.3s; } -ul.tsd-descriptions.active > .tsd-description.fade-out { -webkit-animation: fade-out-delayed 0.3s; animation: fade-out-delayed 0.3s; position: absolute; display: block; top: 0; left: 0; right: 0; opacity: 0; visibility: hidden; } -ul.tsd-descriptions h4, ul.tsd-descriptions .tsd-index-panel h3, .tsd-index-panel ul.tsd-descriptions h3 { font-size: 16px; margin: 1em 0 0.5em 0; } - -ul.tsd-parameters, ul.tsd-type-parameters { list-style: square; margin: 0; padding-left: 20px; } -ul.tsd-parameters > li.tsd-parameter-siganture, ul.tsd-type-parameters > li.tsd-parameter-siganture { list-style: none; margin-left: -20px; } -ul.tsd-parameters h5, ul.tsd-type-parameters h5 { font-size: 16px; margin: 1em 0 0.5em 0; } -ul.tsd-parameters .tsd-comment, ul.tsd-type-parameters .tsd-comment { margin-top: -0.5em; } - -.tsd-sources { font-size: 14px; color: #808080; margin: 0 0 1em 0; } -.tsd-sources a { color: #808080; text-decoration: underline; } -.tsd-sources ul, .tsd-sources p { margin: 0 !important; } -.tsd-sources ul { list-style: none; padding: 0; } - -.tsd-page-toolbar { position: absolute; z-index: 1; top: 0; left: 0; width: 100%; height: 40px; color: #333; background: #fff; border-bottom: 1px solid #eee; } -.tsd-page-toolbar a { color: #333; text-decoration: none; } -.tsd-page-toolbar a.title { font-weight: bold; } -.tsd-page-toolbar a.title:hover { text-decoration: underline; } -.tsd-page-toolbar .table-wrap { display: table; width: 100%; height: 40px; } -.tsd-page-toolbar .table-cell { display: table-cell; position: relative; white-space: nowrap; line-height: 40px; } -.tsd-page-toolbar .table-cell:first-child { width: 100%; } - -.tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { content: ""; display: inline-block; width: 40px; height: 40px; margin: 0 -8px 0 0; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAoCAQAAAAlSeuiAAABp0lEQVR4Ae3aUa3jQAyF4QNhIBTCQiiEQlgIhRAGhTAQBkIgBEIgDITZZGXNjZTePiSWYqn/54dGfbAq+SiTutWXAgAAAAAAAAAAAAA8NCz1UFSD2lKDS5d3NVzZj/BVNasaLoRZRUmj2lLrVVHWMUntQ13Wj/i1pWa9lprX6xMRnH4dx6Rjsn26+v+12ms+EcB37P0r+qH+DNQGXgMFcHzbregQ78B8eQCTJk0e979ZW7PdA2O49ceDsYexKgUNoI3EKYDWL3D8miaPh/uXtl6BHqEHFQvgXau/FsCiIWAAbST2fpQRT0sl70j3z5ZiBdD7CG5WZX8kxwmgjbiP5GQA9/3O2XaxnnHi53AEE0AbRh+JQwC3/fzC4hcb6xPvS4i3QaMdwX+0utsRPEY6gm2wNhKHAG77eUi7SIcK4G4NY4GMIan2u2Cxqzncl5DUn7Q8ArjvZ8JFOsl/Ed0jyBom+BomQKSto+9PcblHMM4iuu4X0QQw5hrGQY/gUxFkjZuf4m4alXVU+1De/VhEn5CvDSB/RsBzqWgAAAAAAAAAAAAAAACAfyyYJ5nhVuwIAAAAAElFTkSuQmCC); background-repeat: no-repeat; text-indent: -1024px; vertical-align: bottom; } -@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAABQCAMAAAC+sjQXAAAAM1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjBUbJAAAAEXRSTlMA3/+/UCBw7xCPYIBAMM+vn1qYQ7QAAALCSURBVHgB7MGBAAAAAICg/akXqQIAAAAAAAAAAAAAAAAAAJids9mdE4bhoDNZCITP93/aSmhV/9uwPWyi8jtkblws2IxsYpz9LwSAaJW8AreE16PxOsMYE6Q4DiYKF7X+8ZHXc/E608xv5snEyIuZrVwMZjbnujR6T3gsXmcLOIRNzD+Ig2UuVtt2+NbAiX/wVLzOlviD9L2BOfGBlL/3D1I+uDjGBJArBPxU3x+K15kCQFo2s21JAOHrKpz4SPrWv4IKA+uFaR6vMwMcb+emA2DWEfDglrkLqEBOKVslA8Dx14oPMiV4CtywWxdQgAwkq2QE0uTXUwJGk2G9s3mTFNBzAkC7HKPsX72AEVjMnAWIpsPCRRjXdQxcjCYpoOcEgHY5Rtk/slWSgM3M2aSeeVgjAOeVpKcdgGMdNAXMuIAqOcZzqF8L+WcAsi8wkTeheCWMegL6mgCorHHyEJ5TVfxrLWDrTUjZdhnhjYqAnlN8TaoELOLVC0gucmoz/3RKcPs2jAs4+J5ET8AEZF+TSgGLeC1V8YuGQQU2IV1Asq9JCwE9XitZVPxr34bpJRj8PqsFLOK108W9aVrWZRrR7Sm2HL4JCToCujHZ6gUs4jUz0P1TEvD+U5wMa363YeziBODIq1YbJrsv9QKW8Ry1nNp+GAHvuingRTfmYcjBf0QpAS37bdUL6PFKtHJq63EsZ5cxcKMkDVIClu1dAK1PcJ5TFQ0M9wZKDCPs3BD7MIJGTs3WfiTfDVQYx5q5ZekCauTU3P5Q0ukGCgh49oFURdobWBY9N/CxEuwGjpGLuPhTdwH1x7HqDDxNgRP2zQ8lraFyF/yJ9vH6QGqtgSbBOU8/j2VORz+Wqfle2d5Ae4R+ML0z7Y+W4P7XHN3AU+tzyK/24EAGAAAAYJC/9T2+CgAAAAAAAAAAAAAAAAAAAADgJpfzHyIKFFBKAAAAAElFTkSuQmCC); background-size: 320px 40px; } } - -.tsd-widget { display: inline-block; overflow: hidden; opacity: 0.6; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } -.tsd-widget:hover { opacity: 0.8; } -.tsd-widget.active { opacity: 1; background-color: #eee; } -.tsd-widget.no-caption { width: 40px; } -.tsd-widget.no-caption:before { margin: 0; } -.tsd-widget.search:before { background-position: 0 0; } -.tsd-widget.menu:before { background-position: -40px 0; } -.tsd-widget.options:before { background-position: -80px 0; } -.tsd-widget.options, .tsd-widget.menu { display: none; } -@media (max-width: 900px) { .tsd-widget.options, .tsd-widget.menu { display: inline-block; } } -input[type=checkbox] + .tsd-widget:before { background-position: -120px 0; } -input[type=checkbox]:checked + .tsd-widget:before { background-position: -160px 0; } - -.tsd-select { position: relative; display: inline-block; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } -.tsd-select .tsd-select-label { opacity: 0.6; -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -.tsd-select .tsd-select-label:before { background-position: -240px 0; } -.tsd-select.active .tsd-select-label { opacity: 0.8; } -.tsd-select.active .tsd-select-list { visibility: visible; opacity: 1; -webkit-transition-delay: 0s; transition-delay: 0s; } -.tsd-select .tsd-select-list { position: absolute; visibility: hidden; top: 40px; left: 0; margin: 0; padding: 0; opacity: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); -webkit-transition: visibility 0s 0.2s, opacity 0.2s; transition: visibility 0s 0.2s, opacity 0.2s; } -.tsd-select .tsd-select-list li { padding: 0 20px 0 0; background-color: #fdfdfd; } -.tsd-select .tsd-select-list li:before { background-position: 40px 0; } -.tsd-select .tsd-select-list li:nth-child(even) { background-color: #fff; } -.tsd-select .tsd-select-list li:hover { background-color: #eee; } -.tsd-select .tsd-select-list li.selected:before { background-position: -200px 0; } -@media (max-width: 900px) { .tsd-select .tsd-select-list { top: 0; left: auto; right: 100%; margin-right: -5px; } - .tsd-select .tsd-select-label:before { background-position: -280px 0; } } diff --git a/docs/css/extra.css b/docs/css/extra.css deleted file mode 100644 index 804bb597f..000000000 --- a/docs/css/extra.css +++ /dev/null @@ -1,14 +0,0 @@ -pre code { - white-space: pre; - word-wrap: normal; - display: block; - padding: 12px; - font-size: 14px; -} - -code { - white-space: pre-wrap; - word-wrap: break-word; - padding: 2px 5px; - font-size: 16px; -} diff --git a/docs/css/github-highlight.css b/docs/css/github-highlight.css deleted file mode 100644 index 52b18879c..000000000 --- a/docs/css/github-highlight.css +++ /dev/null @@ -1,224 +0,0 @@ -pre { - border: none !important; - background-color: #fff !important; -} -code { - color: inherit !important; - background-color: #fff; -} -pre, code { - white-space: pre !important; -} -.highlight table pre { margin: 0; } -.highlight .cm { - color: #999988; - font-style: italic; -} -.highlight .cp { - color: #999999; - font-weight: bold; -} -.highlight .c1 { - color: #999988; - font-style: italic; -} -.highlight .cs { - color: #999999; - font-weight: bold; - font-style: italic; -} -.highlight .c, .highlight .cd { - color: #999988; - font-style: italic; -} -.highlight .err { - color: #a61717; - background-color: #e3d2d2; -} -.highlight .gd { - color: #000000; - background-color: #ffdddd; -} -.highlight .ge { - color: #000000; - font-style: italic; -} -.highlight .gr { - color: #aa0000; -} -.highlight .gh { - color: #999999; -} -.highlight .gi { - color: #000000; - background-color: #ddffdd; -} -.highlight .go { - color: #888888; -} -.highlight .gp { - color: #555555; -} -.highlight .gs { - font-weight: bold; -} -.highlight .gu { - color: #aaaaaa; -} -.highlight .gt { - color: #aa0000; -} -.highlight .kc { - color: #000000; - font-weight: bold; -} -.highlight .kd { - color: #000000; - font-weight: bold; -} -.highlight .kn { - color: #000000; - font-weight: bold; -} -.highlight .kp { - color: #000000; - font-weight: bold; -} -.highlight .kr { - color: #000000; - font-weight: bold; -} -.highlight .kt { - color: #445588; - font-weight: bold; -} -.highlight .k, .highlight .kv { - color: #000000; - font-weight: bold; -} -.highlight .mf { - color: #009999; -} -.highlight .mh { - color: #009999; -} -.highlight .il { - color: #009999; -} -.highlight .mi { - color: #009999; -} -.highlight .mo { - color: #009999; -} -.highlight .m, .highlight .mb, .highlight .mx { - color: #009999; -} -.highlight .sb { - color: #d14; -} -.highlight .sc { - color: #d14; -} -.highlight .sd { - color: #d14; -} -.highlight .s2 { - color: #d14; -} -.highlight .se { - color: #d14; -} -.highlight .sh { - color: #d14; -} -.highlight .si { - color: #d14; -} -.highlight .sx { - color: #d14; -} -.highlight .sr { - color: #009926; -} -.highlight .s1 { - color: #d14; -} -.highlight .ss { - color: #990073; -} -.highlight .s { - color: #d14; -} -.highlight .na { - color: #008080; -} -.highlight .bp { - color: #999999; -} -.highlight .nb { - color: #0086B3; -} -.highlight .nc { - color: #445588; - font-weight: bold; -} -.highlight .no { - color: #008080; -} -.highlight .nd { - color: #3c5d5d; - font-weight: bold; -} -.highlight .ni { - color: #800080; -} -.highlight .ne { - color: #990000; - font-weight: bold; -} -.highlight .nf { - color: #990000; - font-weight: bold; -} -.highlight .nl { - color: #990000; - font-weight: bold; -} -.highlight .nn { - color: #555555; -} -.highlight .nt { - color: #000080; -} -.highlight .vc { - color: #008080; -} -.highlight .vg { - color: #008080; -} -.highlight .vi { - color: #008080; -} -.highlight .nv { - color: #008080; -} -.highlight .ow { - color: #000000; - font-weight: bold; -} -.highlight .o { - color: #000000; - font-weight: bold; -} -.highlight .w { - color: #bbbbbb; -} -.highlight { - background-color: #fff; - padding-bottom: 0px; -} -.highlighter-rouge { - border: 1px solid #ccc; - margin-bottom: 1em; -} diff --git a/docs/css/main.css b/docs/css/main.css deleted file mode 100644 index 05fd446ec..000000000 --- a/docs/css/main.css +++ /dev/null @@ -1,276 +0,0 @@ -/* General CSS */ -@import url(https://fonts.googleapis.com/css?family=Open+Sans); - -body { - font-family: "Open Sans", Helvetica, Arial, sans-serif; -} - -img { - max-width: 90%; -} - -/* Custom CSS for home page */ -.hero-spacer { - margin-top: 50px; -} - -.hero-feature { - margin-bottom: 30px; -} - -.blog-content-wrap { - margin-top: 20px; - padding-top: 40px; -} - -/* Custom CSS for the docs */ - -.edit-links { - color: #cccccc; -} - -/* Prevent in-page links from scrolling under top nav */ -h2::before, h3::before { - display: block; - content: " "; - margin-top: -80px; - height: 80px; - visibility: hidden; -} - -/* Custom CSS for header and footer */ -.site-header -{ - width: 100%; - padding: 24px 0px; -} - -img.logo -{ - height: 28px; - margin-left: -18px; -} - -.icon > svg -{ - display: inline-block; - width: 28px; - height: 28px; -} - -.icon > svg path -{ - fill: #333333; -} - - -.thumb-home { - height: 340px; -} - -.img-home { - height: 150px !important; - margin: 10px; -} - -.tableauIcon { - margin-left: 18px; -} - -.jumbotron { - background-color: #fafafa; - border: 1px solid #e8e8e8; -} - -#community-jumbo { - background-color: #F0F8FF; -} - -footer { - margin: 30px 0; - text-align: center; -} - -.footer-hr { - width: 100%; - position: relative; -} - -table { - border: 1px solid #c2c2c2; - border-collapse: collapse; - margin: 1em 0px; -} - -th -{ - font-weight: bold; - border: 1px solid #c2c2c2; - text-align: left; - padding: .3em; - vertical-align: top; - background-color: #fafafa; -} - -td -{ - border: 1px solid #c2c2c2; - text-align: left; - padding: .3em; - vertical-align: top; -} - -/* So the scroll bar width doesn't cause the page to jump */ -html { - overflow-y: scroll; -} - -.label { - margin-right: 3px; -} - -/* to get right navbar icons to respect collapse */ -@media (max-width: 992px) { - .navbar-header { - float: none; - } - .navbar-left,.navbar-right { - float: none !important; - } - .navbar-toggle { - display: block; - } - .navbar-collapse { - border-top: 1px solid transparent; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.1); - } - .navbar-fixed-top { - top: 0; - border-width: 0 0 1px; - } - .navbar-collapse.collapse { - display: none!important; - } - .navbar-nav { - float: none!important; - margin-top: 7.5px; - } - .navbar-nav>li { - float: none; - } - .navbar-nav>li>a { - padding-top: 10px; - padding-bottom: 10px; - } - .collapse.in{ - display:block !important; - } -} - - -/* Custom css for news section */ -.blog-content { - margin-bottom: 70px; -} - -.blogul { - padding: 0px; -} - -.blogul h1 { - margin-top: 40px; -} - -.blog-content > h4 { - margin-top: 20px; -} - -.blog-content > ol { - margin-bottom: 20px; -} - - -/* Community connectors */ -.thumbnail { - background-color: #fff; - border: 1px solid #ccc; - margin: 12px; -} - -.thumbnail h2 { - border-left: 2px solid #337ab7; - text-decoration: none; - font-size: 20px; - padding: 6px; - margin-left: 10px; -} - -.thumbnail h2 a { - text-decoration: none; - color: #333333; -} - -.well { - background-color: #ffffff; - margin-bottom: 40px; -} - -.tsd-navigation { - padding: 10px !important; -} - -/* Media queries for responsive design */ -#grid[data-columns]::before { - content: '3 .column.size-1of3'; -} - -.column { float: left; } -.size-1of3 { width: 33.333%; } - - -@media screen and (max-width: 767px) { - #grid[data-columns]::before { - content: '1 .column.size-1of1'; - } - - /* Docs Menu*/ - .docs-menu, .tsd-navigation { - top: 20px; - position: relative; - } - -} -@media screen and (min-width: 769px) { - #grid[data-columns]::before { - content: '3 .column.size-1of3'; - } - - /* Docs Menu*/ - .docs-menu, .tsd-navigation { - position: fixed; - overflow: auto; - top: 90px; - max-height: 90%; - max-width: 250px; - } - - .content { - position: relative; - margin: 40px 0px 0px 275px; - max-width: 1000px; - } - - /* API Reference */ - - .ref-content { - margin: 40px 0px 0px 275px; - max-width: 1000px; - } -} - -.column { float: left; } -.size-1of1 { width: 100%; } -.size-1of2 { width: 50%; } -.size-1of3 { width: 33.333%; } - - diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index c0f9a4431..000000000 --- a/docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: home -indexed_by_search: false ---- - -
-

Tableau Server Client (Python)

-

The Tableau Server Client is a Python library for the Tableau Server REST API.

-
- Get Started   - Download -
- diff --git a/docs/js/lunr.min.js b/docs/js/lunr.min.js deleted file mode 100644 index 22776bb85..000000000 --- a/docs/js/lunr.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 0.7.2 - * Copyright (C) 2016 Oliver Nightingale - * @license MIT - */ -!function(){var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.7.2",t.utils={},t.utils.warn=function(t){return function(e){t.console&&console.warn&&console.warn(e)}}(this),t.utils.asString=function(t){return void 0===t||null===t?"":t.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var t=Array.prototype.slice.call(arguments),e=t.pop(),n=t;if("function"!=typeof e)throw new TypeError("last argument must be a function");n.forEach(function(t){this.hasHandler(t)||(this.events[t]=[]),this.events[t].push(e)},this)},t.EventEmitter.prototype.removeListener=function(t,e){if(this.hasHandler(t)){var n=this.events[t].indexOf(e);this.events[t].splice(n,1),this.events[t].length||delete this.events[t]}},t.EventEmitter.prototype.emit=function(t){if(this.hasHandler(t)){var e=Array.prototype.slice.call(arguments,1);this.events[t].forEach(function(t){t.apply(void 0,e)})}},t.EventEmitter.prototype.hasHandler=function(t){return t in this.events},t.tokenizer=function(e){if(!arguments.length||null==e||void 0==e)return[];if(Array.isArray(e))return e.map(function(e){return t.utils.asString(e).toLowerCase()});var n=t.tokenizer.seperator||t.tokenizer.separator;return e.toString().trim().toLowerCase().split(n)},t.tokenizer.seperator=!1,t.tokenizer.separator=/[\s\-]+/,t.tokenizer.load=function(t){var e=this.registeredFunctions[t];if(!e)throw new Error("Cannot load un-registered function: "+t);return e},t.tokenizer.label="default",t.tokenizer.registeredFunctions={"default":t.tokenizer},t.tokenizer.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing tokenizer: "+n),e.label=n,this.registeredFunctions[n]=e},t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.registeredFunctions[e];if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._stack.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");i+=1,this._stack.splice(i,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");this._stack.splice(i,0,n)},t.Pipeline.prototype.remove=function(t){var e=this._stack.indexOf(t);-1!=e&&this._stack.splice(e,1)},t.Pipeline.prototype.run=function(t){for(var e=[],n=t.length,i=this._stack.length,r=0;n>r;r++){for(var o=t[r],s=0;i>s&&(o=this._stack[s](o,r,t),void 0!==o&&""!==o);s++);void 0!==o&&""!==o&&e.push(o)}return e},t.Pipeline.prototype.reset=function(){this._stack=[]},t.Pipeline.prototype.toJSON=function(){return this._stack.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Vector=function(){this._magnitude=null,this.list=void 0,this.length=0},t.Vector.Node=function(t,e,n){this.idx=t,this.val=e,this.next=n},t.Vector.prototype.insert=function(e,n){this._magnitude=void 0;var i=this.list;if(!i)return this.list=new t.Vector.Node(e,n,i),this.length++;if(en.idx?n=n.next:(i+=e.val*n.val,e=e.next,n=n.next);return i},t.Vector.prototype.similarity=function(t){return this.dot(t)/(this.magnitude()*t.magnitude())},t.SortedSet=function(){this.length=0,this.elements=[]},t.SortedSet.load=function(t){var e=new this;return e.elements=t,e.length=t.length,e},t.SortedSet.prototype.add=function(){var t,e;for(t=0;t1;){if(o===t)return r;t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r]}return o===t?r:-1},t.SortedSet.prototype.locationFor=function(t){for(var e=0,n=this.elements.length,i=n-e,r=e+Math.floor(i/2),o=this.elements[r];i>1;)t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r];return o>t?r:t>o?r+1:void 0},t.SortedSet.prototype.intersect=function(e){for(var n=new t.SortedSet,i=0,r=0,o=this.length,s=e.length,a=this.elements,h=e.elements;;){if(i>o-1||r>s-1)break;a[i]!==h[r]?a[i]h[r]&&r++:(n.add(a[i]),i++,r++)}return n},t.SortedSet.prototype.clone=function(){var e=new t.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},t.SortedSet.prototype.union=function(t){var e,n,i;this.length>=t.length?(e=this,n=t):(e=t,n=this),i=e.clone();for(var r=0,o=n.toArray();rp;p++)c[p]===a&&d++;h+=d/f*l.boost}}this.tokenStore.add(a,{ref:o,tf:h})}n&&this.eventEmitter.emit("add",e,this)},t.Index.prototype.remove=function(t,e){var n=t[this._ref],e=void 0===e?!0:e;if(this.documentStore.has(n)){var i=this.documentStore.get(n);this.documentStore.remove(n),i.forEach(function(t){this.tokenStore.remove(t,n)},this),e&&this.eventEmitter.emit("remove",t,this)}},t.Index.prototype.update=function(t,e){var e=void 0===e?!0:e;this.remove(t,!1),this.add(t,!1),e&&this.eventEmitter.emit("update",t,this)},t.Index.prototype.idf=function(t){var e="@"+t;if(Object.prototype.hasOwnProperty.call(this._idfCache,e))return this._idfCache[e];var n=this.tokenStore.count(t),i=1;return n>0&&(i=1+Math.log(this.documentStore.length/n)),this._idfCache[e]=i},t.Index.prototype.search=function(e){var n=this.pipeline.run(this.tokenizerFn(e)),i=new t.Vector,r=[],o=this._fields.reduce(function(t,e){return t+e.boost},0),s=n.some(function(t){return this.tokenStore.has(t)},this);if(!s)return[];n.forEach(function(e,n,s){var a=1/s.length*this._fields.length*o,h=this,u=this.tokenStore.expand(e).reduce(function(n,r){var o=h.corpusTokens.indexOf(r),s=h.idf(r),u=1,l=new t.SortedSet;if(r!==e){var c=Math.max(3,r.length-e.length);u=1/Math.log(c)}o>-1&&i.insert(o,a*s*u);for(var f=h.tokenStore.get(r),d=Object.keys(f),p=d.length,v=0;p>v;v++)l.add(f[d[v]].ref);return n.union(l)},new t.SortedSet);r.push(u)},this);var a=r.reduce(function(t,e){return t.intersect(e)});return a.map(function(t){return{ref:t,score:i.similarity(this.documentVector(t))}},this).sort(function(t,e){return e.score-t.score})},t.Index.prototype.documentVector=function(e){for(var n=this.documentStore.get(e),i=n.length,r=new t.Vector,o=0;i>o;o++){var s=n.elements[o],a=this.tokenStore.get(s)[e].tf,h=this.idf(s);r.insert(this.corpusTokens.indexOf(s),a*h)}return r},t.Index.prototype.toJSON=function(){return{version:t.version,fields:this._fields,ref:this._ref,tokenizer:this.tokenizerFn.label,documentStore:this.documentStore.toJSON(),tokenStore:this.tokenStore.toJSON(),corpusTokens:this.corpusTokens.toJSON(),pipeline:this.pipeline.toJSON()}},t.Index.prototype.use=function(t){var e=Array.prototype.slice.call(arguments,1);e.unshift(this),t.apply(this,e)},t.Store=function(){this.store={},this.length=0},t.Store.load=function(e){var n=new this;return n.length=e.length,n.store=Object.keys(e.store).reduce(function(n,i){return n[i]=t.SortedSet.load(e.store[i]),n},{}),n},t.Store.prototype.set=function(t,e){this.has(t)||this.length++,this.store[t]=e},t.Store.prototype.get=function(t){return this.store[t]},t.Store.prototype.has=function(t){return t in this.store},t.Store.prototype.remove=function(t){this.has(t)&&(delete this.store[t],this.length--)},t.Store.prototype.toJSON=function(){return{store:this.store,length:this.length}},t.stemmer=function(){var t={ational:"ate",tional:"tion",enci:"ence",anci:"ance",izer:"ize",bli:"ble",alli:"al",entli:"ent",eli:"e",ousli:"ous",ization:"ize",ation:"ate",ator:"ate",alism:"al",iveness:"ive",fulness:"ful",ousness:"ous",aliti:"al",iviti:"ive",biliti:"ble",logi:"log"},e={icate:"ic",ative:"",alize:"al",iciti:"ic",ical:"ic",ful:"",ness:""},n="[^aeiou]",i="[aeiouy]",r=n+"[^aeiouy]*",o=i+"[aeiou]*",s="^("+r+")?"+o+r,a="^("+r+")?"+o+r+"("+o+")?$",h="^("+r+")?"+o+r+o+r,u="^("+r+")?"+i,l=new RegExp(s),c=new RegExp(h),f=new RegExp(a),d=new RegExp(u),p=/^(.+?)(ss|i)es$/,v=/^(.+?)([^s])s$/,g=/^(.+?)eed$/,m=/^(.+?)(ed|ing)$/,y=/.$/,S=/(at|bl|iz)$/,w=new RegExp("([^aeiouylsz])\\1$"),k=new RegExp("^"+r+i+"[^aeiouwxy]$"),x=/^(.+?[^aeiou])y$/,b=/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/,E=/^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/,F=/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/,_=/^(.+?)(s|t)(ion)$/,z=/^(.+?)e$/,O=/ll$/,P=new RegExp("^"+r+i+"[^aeiouwxy]$"),T=function(n){var i,r,o,s,a,h,u;if(n.length<3)return n;if(o=n.substr(0,1),"y"==o&&(n=o.toUpperCase()+n.substr(1)),s=p,a=v,s.test(n)?n=n.replace(s,"$1$2"):a.test(n)&&(n=n.replace(a,"$1$2")),s=g,a=m,s.test(n)){var T=s.exec(n);s=l,s.test(T[1])&&(s=y,n=n.replace(s,""))}else if(a.test(n)){var T=a.exec(n);i=T[1],a=d,a.test(i)&&(n=i,a=S,h=w,u=k,a.test(n)?n+="e":h.test(n)?(s=y,n=n.replace(s,"")):u.test(n)&&(n+="e"))}if(s=x,s.test(n)){var T=s.exec(n);i=T[1],n=i+"i"}if(s=b,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+t[r])}if(s=E,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+e[r])}if(s=F,a=_,s.test(n)){var T=s.exec(n);i=T[1],s=c,s.test(i)&&(n=i)}else if(a.test(n)){var T=a.exec(n);i=T[1]+T[2],a=c,a.test(i)&&(n=i)}if(s=z,s.test(n)){var T=s.exec(n);i=T[1],s=c,a=f,h=P,(s.test(i)||a.test(i)&&!h.test(i))&&(n=i)}return s=O,a=c,s.test(n)&&a.test(n)&&(s=y,n=n.replace(s,"")),"y"==o&&(n=o.toLowerCase()+n.substr(1)),n};return T}(),t.Pipeline.registerFunction(t.stemmer,"stemmer"),t.generateStopWordFilter=function(t){var e=t.reduce(function(t,e){return t[e]=e,t},{});return function(t){return t&&e[t]!==t?t:void 0}},t.stopWordFilter=t.generateStopWordFilter(["a","able","about","across","after","all","almost","also","am","among","an","and","any","are","as","at","be","because","been","but","by","can","cannot","could","dear","did","do","does","either","else","ever","every","for","from","get","got","had","has","have","he","her","hers","him","his","how","however","i","if","in","into","is","it","its","just","least","let","like","likely","may","me","might","most","must","my","neither","no","nor","not","of","off","often","on","only","or","other","our","own","rather","said","say","says","she","should","since","so","some","than","that","the","their","them","then","there","these","they","this","tis","to","too","twas","us","wants","was","we","were","what","when","where","which","while","who","whom","why","will","with","would","yet","you","your"]),t.Pipeline.registerFunction(t.stopWordFilter,"stopWordFilter"),t.trimmer=function(t){return t.replace(/^\W+/,"").replace(/\W+$/,"")},t.Pipeline.registerFunction(t.trimmer,"trimmer"),t.TokenStore=function(){this.root={docs:{}},this.length=0},t.TokenStore.load=function(t){var e=new this;return e.root=t.root,e.length=t.length,e},t.TokenStore.prototype.add=function(t,e,n){var n=n||this.root,i=t.charAt(0),r=t.slice(1);return i in n||(n[i]={docs:{}}),0===r.length?(n[i].docs[e.ref]=e,void(this.length+=1)):this.add(r,e,n[i])},t.TokenStore.prototype.has=function(t){if(!t)return!1;for(var e=this.root,n=0;n" + title + ""; - container.innerHTML += "

" + getResultBlurb(search_blob[ref].content) + "...


"; - } - } - } else { - container.innerHTML += "
No results found.
"; - } - } - - addContentToIndex(); - window.addEventListener("load", function () { - displaySearchHeading(searchQuery); - displaySearchResults(getRawSearchResults(searchQuery)); - }); - -})(); From 5eccf20eb3f518d884343a71e6c8b7b0d088a1bc Mon Sep 17 00:00:00 2001 From: Sherman K Date: Wed, 6 Nov 2019 03:17:06 +0800 Subject: [PATCH 110/567] Close missing brackets in log texts (#508) * Close missing brackets in log texts * Fix linting issue with missing space --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 3d81e3999..80bc38a7c 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -86,7 +86,7 @@ def update(self, workbook_item): url = "{0}/{1}".format(self.baseurl, workbook_item.id) update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info('Updated workbook item (ID: {0}'.format(workbook_item.id)) + logger.info('Updated workbook item (ID: {0})'.format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -104,8 +104,8 @@ def update_connection(self, workbook_item, connection_item): server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Updated workbook item (ID: {0} & connection item {1}'.format(workbook_item.id, - connection_item.id)) + logger.info('Updated workbook item (ID: {0} & connection item {1})'.format(workbook_item.id, + connection_item.id)) return connection # Download workbook contents with option of passing in filepath @@ -149,7 +149,7 @@ def view_fetcher(): return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info('Populated views for workbook (ID: {0}'.format(workbook_item.id)) + logger.info('Populated views for workbook (ID: {0})'.format(workbook_item.id)) def _get_views_for_workbook(self, workbook_item, usage): url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) From c366694ef2823c910e72b763d8e420dcc66cde40 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 5 Nov 2019 13:54:21 -0800 Subject: [PATCH 111/567] Fixes move_workbook_sites sample to work properly (#503) --- samples/move_workbook_sites.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index d81c96767..40f0350e5 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -58,7 +58,7 @@ def main(): workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir) # Step 4: Check if destination site exists, then sign in to the site - pagination_info, all_sites = source_server.sites.get() + all_sites, pagination_info = source_server.sites.get() found_destination_site = any((True for site in all_sites if args.destination_site.lower() == site.content_url.lower())) if not found_destination_site: @@ -71,21 +71,14 @@ def main(): # because of the different auth token and site ID. with dest_server.auth.sign_in(tableau_auth): - # Step 5: Find destination site's default project - pagination_info, dest_projects = dest_server.projects.get() - target_project = next((project for project in dest_projects if project.is_default()), None) - - # Step 6: If default project is found, form a new workbook item and publish. - if target_project is not None: - new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id=target_project.id) - new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, - mode=TSC.Server.PublishMode.Overwrite) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) - else: - error = "The default project could not be found." - raise LookupError(error) - - # Step 7: Delete workbook from source site and delete temp directory + # Step 5: Create a new workbook item and publish workbook. Note that + # an empty project_id will publish to the 'Default' project. + new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") + new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, + mode=TSC.Server.PublishMode.Overwrite) + print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + + # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) finally: From 9c19aa39fe6e73843e182783270d8026e2bf95c6 Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Tue, 12 Nov 2019 10:16:06 -0800 Subject: [PATCH 112/567] Add webhooks (#523) * Fix a decreated warning in the tests * Dropping 2.7 support since it's EOLEOY * fixes deprecated warning in test_requests.py * fixes deprecated warning in test_datasource.py * fixed deprecated warning in test_workbook * Fixing incorrect version for publish_async * Fix deprecrated warning in test_regression_tests.py * initial working version * removing print statements * pep8 fixes in test * fixing pep8 failures in tableauserverclient * Make token optional if it's set in the environment * read events properly * read events properly * fix request generation * fix pep8 error * Tyler's feedback --- .travis.yml | 1 - samples/list.py | 22 ++--- tableauserverclient/__init__.py | 3 +- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/pagination_item.py | 9 ++ .../models/personal_access_token_auth.py | 3 + tableauserverclient/models/webhook_item.py | 89 +++++++++++++++++++ tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/__init__.py | 3 +- .../server/endpoint/webhooks_endpoint.py | 53 +++++++++++ .../server/endpoint/workbooks_endpoint.py | 1 - tableauserverclient/server/request_factory.py | 20 ++++- tableauserverclient/server/server.py | 3 +- test/assets/webhook_create.xml | 12 +++ test/assets/webhook_create_request.xml | 1 + test/assets/webhook_get.xml | 14 +++ test/test_datasource.py | 14 +-- test/test_job.py | 16 ++-- test/test_regression_tests.py | 6 +- test/test_requests.py | 4 +- test/test_webhook.py | 77 ++++++++++++++++ test/test_workbook.py | 8 +- 22 files changed, 324 insertions(+), 39 deletions(-) create mode 100644 tableauserverclient/models/webhook_item.py create mode 100644 tableauserverclient/server/endpoint/webhooks_endpoint.py create mode 100644 test/assets/webhook_create.xml create mode 100644 test/assets/webhook_create_request.xml create mode 100644 test/assets/webhook_get.xml create mode 100644 test/test_webhook.py 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) From bdc621562f001361f8685cf1a66d618eabbd3441 Mon Sep 17 00:00:00 2001 From: Martin Peters Date: Mon, 18 Nov 2019 21:21:10 +0000 Subject: [PATCH 113/567] Project permissions fixes and tests (#527) * Fixed project permission methods and added DELETE tests --- .../server/endpoint/projects_endpoint.py | 24 ++--- test/test_project.py | 94 +++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index e4dafcbcc..3b5216899 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -86,25 +86,25 @@ def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) @api(version='2.1') - def update_workbook_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Workbook) + def update_workbook_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @api(version='2.1') - def update_datasource_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Datasource) + def update_datasource_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) @api(version='3.4') - def update_flow_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Flow) + def update_flow_default_permissions(self, item, rules): + self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) @api(version='2.1') - def delete_workbook_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Workbook) + def delete_workbook_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @api(version='2.1') - def delete_datasource_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Datasource) + def delete_datasource_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource) @api(version='3.4') - def delete_flow_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Flow) + def delete_flow_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) diff --git a/test/test_project.py b/test/test_project.py index 6e055e50f..d4a0de283 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -152,3 +152,97 @@ def test_populate_workbooks(self): TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, }) + + def test_delete_permission(self): + with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + + single_group = TSC.GroupItem('Group1') + single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + + single_project = TSC.ProjectItem('Project3') + single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + + self.server.projects.populate_permissions(single_project) + permissions = single_project.permissions + + capabilities = {} + + for permission in permissions: + if permission.grantee.tag_name == "group": + if permission.grantee.id == single_group._id: + capabilities = permission.capabilities + + rules = TSC.PermissionsRule( + grantee=single_group, + capabilities=capabilities + ) + + endpoint = '{}/permissions/groups/{}'.format(single_project._id, single_group._id) + m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + self.server.projects.delete_permission(item=single_project, rules=rules) + + def test_delete_workbook_default_permission(self): + with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + + with requests_mock.mock() as m: + m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', + text=response_xml) + + single_group = TSC.GroupItem('Group1') + single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + + single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + + self.server.projects.populate_workbook_default_permissions(single_project) + permissions = single_project.default_workbook_permissions + + capabilities = { + # View + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + + # Interact/Edit + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + + # Edit + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow + } + + rules = TSC.PermissionsRule( + grantee=single_group, + capabilities=capabilities + ) + + endpoint = '{}/default-permissions/workbook/groups/{}'.format(single_project._id, single_group._id) + m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportImage/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportData/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ViewComments/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/AddComment/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Filter/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ViewUnderlyingData/Deny'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ShareView/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/WebAuthoring/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ExportXml/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ChangeHierarchy/Allow'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/Delete/Deny'.format(self.baseurl, endpoint), status_code=204) + m.delete('{}/{}/ChangePermissions/Allow'.format(self.baseurl, endpoint), status_code=204) + self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) From 8c3a40a8d5c55c7aebb507809b07fd066dea9680 Mon Sep 17 00:00:00 2001 From: Martin Peters Date: Mon, 18 Nov 2019 23:59:15 +0000 Subject: [PATCH 114/567] Added Tasks Delete method & tests (#524) * Added tasks delete method --- tableauserverclient/server/endpoint/tasks_endpoint.py | 10 ++++++++++ test/test_task.py | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 1c93181df..7df5bc0ad 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -42,3 +42,13 @@ def run(self, task_item): run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) return server_response.content + + # Delete 1 task by id + @api(version="3.6") + def delete(self, task_id): + if not task_id: + error = "No Task ID provided" + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, task_id) + self.delete_request(url) + logger.info('Deleted single task (ID: {0})'.format(task_id)) diff --git a/test/test_task.py b/test/test_task.py index 2529f811a..ea22a24c7 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -14,7 +14,7 @@ class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test") - self.server.version = '2.6' + self.server.version = '3.6' # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -76,3 +76,11 @@ def test_get_task_with_schedule(self): self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) self.assertEqual('workbook', task.target.type) self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/c7a9327e-1cda-4504-b026-ddb43b976d1d', status_code=204) + self.server.tasks.delete('c7a9327e-1cda-4504-b026-ddb43b976d1d') + + def test_delete_missing_id(self): + self.assertRaises(ValueError, self.server.tasks.delete, '') From 04ed840f3eaf5e8854c4e4811f2631a5856f6a52 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Nov 2019 14:56:00 -0800 Subject: [PATCH 115/567] add test endpoint for webhooks and sample code to use them --- samples/explore_webhooks.py | 81 +++++++++++++++++++ .../server/endpoint/webhooks_endpoint.py | 9 +++ test/test_webhook.py | 12 ++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 samples/explore_webhooks.py diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py new file mode 100644 index 000000000..052e9a0d6 --- /dev/null +++ b/samples/explore_webhooks.py @@ -0,0 +1,81 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with webhooks. It explores the different +# functions that the Server API supports on webhooks. +# +# With no flags set, this sample will query all webhooks, +# pick one webhook and print the name of the webhook. +# Adding flags will demonstrate the specific feature +# on top of the general operations. +#### + +import argparse +import getpass +import logging +import os.path + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description='Explore webhook functions supported by the Server API.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', default=None) + parser.add_argument('-p', default=None, help='password') + parser.add_argument('--create', '-c', help='create a webhook') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + args = parser.parse_args() + + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # 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) + print("Signing in to " + args.server + " [" + args.site + "] as " + args.username) + server = TSC.Server(args.server) + + # Set http options to disable verifying SSL + server.add_http_options({'verify': False}) + + server.use_server_version() + + with server.auth.sign_in(tableau_auth): + + # Publish webhook if publish flag is set (-publish, -p) + if args.create: + + new_webhook = TSC.WebhookItem() + new_webhook.name = args.create + new_webhook.url = "https://ifttt.com/maker-url" + new_webhook.event = "datasource-created" + print(new_webhook) + new_webhook = server.webhooks.create(new_webhook) + print("Webhook created. ID: {}".format(new_webhook.id)) + + # Gets all webhook items + all_webhooks, pagination_item = server.webhooks.get() + print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print([webhook.name for webhook in all_webhooks]) + + if all_webhooks: + # Pick one webhook from the list and delete it + sample_webhook = all_webhooks[0] + # sample_webhook.delete() + print("+++"+sample_webhook.name) + + + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index c1e188982..b6ad4b751 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -51,3 +51,12 @@ def create(self, webhook_item): logger.info('Created new webhook (ID: {0})'.format(new_webhook.id)) return new_webhook + + @api(version="3.6") + def test(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + url = "{0}/{1}/test".format(self.baseurl, webhook_id) + testOutcome = self.get_request(url) + logger.info('Testing webhook (ID: {0} returned {1})'.format(webhook_id, testOutcome)) diff --git a/test/test_webhook.py b/test/test_webhook.py index bdf25bb19..5e124a6c7 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -51,6 +51,11 @@ def test_delete(self): def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.webhooks.delete, '') + def test_test(self): + with requests_mock.mock() as m: + m.get(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test', status_code=200) + self.server.webhooks.test('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_create(self): with open(CREATE_XML, 'rb') as f: response_xml = f.read().decode('utf-8') @@ -58,8 +63,9 @@ def test_create(self): 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.url = "https://ifttt.com/maker-url" + new_webhook.event = "datasource-created" + new_webhook.owner_id = new_webhook = self.server.webhooks.create(new_webhook) @@ -72,6 +78,6 @@ def test_request_factory(self): 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')) + webhook_request_actual = '{}\r\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) self.maxDiff = None self.assertEqual(webhook_request_expected, webhook_request_actual) From 1c60b011943c5de9acf7c1a742d7e8f1efe20f63 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Nov 2019 14:56:34 -0800 Subject: [PATCH 116/567] add some getting started instructions for contributors --- contributing.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contributing.md b/contributing.md index c95191e0e..4adebc091 100644 --- a/contributing.md +++ b/contributing.md @@ -53,3 +53,10 @@ creating a PR can be found in the [Development Guide](https://tableau.github.io/ If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle somewhere. + + +## Getting Started +> pip install versioneer +> python setup.py build +> python setup.py test +> From 9a1a43aac9d4f90a4710e41e7e947c09c063128f Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Nov 2019 17:58:44 -0800 Subject: [PATCH 117/567] address cr feedback --- samples/explore_webhooks.py | 6 +++++- .../server/endpoint/webhooks_endpoint.py | 2 ++ test/test_webhook.py | 11 +++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 052e9a0d6..64228b6f0 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -25,6 +25,7 @@ def main(): parser.add_argument('--site', '-S', default=None) parser.add_argument('-p', default=None, help='password') parser.add_argument('--create', '-c', help='create a webhook') + parser.add_argument('--delete', '-d', help='delete a webhook', action='store_true') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') @@ -52,7 +53,7 @@ def main(): with server.auth.sign_in(tableau_auth): - # Publish webhook if publish flag is set (-publish, -p) + # Create webhook if create flag is set (-create, -c) if args.create: new_webhook = TSC.WebhookItem() @@ -74,6 +75,9 @@ def main(): # sample_webhook.delete() print("+++"+sample_webhook.name) + if (args.delete): + print("Deleting webhook " + sample_webhook.name) + server.webhooks.delete(sample_webhook.id) diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index b6ad4b751..3dc28e04a 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -60,3 +60,5 @@ def test(self, webhook_id): url = "{0}/{1}/test".format(self.baseurl, webhook_id) testOutcome = self.get_request(url) logger.info('Testing webhook (ID: {0} returned {1})'.format(webhook_id, testOutcome)) + return testOutcome + diff --git a/test/test_webhook.py b/test/test_webhook.py index 5e124a6c7..e1968495e 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -61,13 +61,12 @@ def test_create(self): 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 = "https://ifttt.com/maker-url" - new_webhook.event = "datasource-created" - new_webhook.owner_id = + webhook_model = TSC.WebhookItem() + webhook_model.name = "Test Webhook" + webhook_model.url = "https://ifttt.com/maker-url" + webhook_model.event = "datasource-created" - new_webhook = self.server.webhooks.create(new_webhook) + new_webhook = self.server.webhooks.create(webhook_model) self.assertNotEqual(new_webhook.id, None) From 83eb7852a8012a815ea2d44acb0776c14a2166ca Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 25 Nov 2019 11:34:32 -0800 Subject: [PATCH 118/567] remove schrodingers newline looks like this newline is os specific, CI doesn't like it --- test/test_webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_webhook.py b/test/test_webhook.py index 5e124a6c7..520326ec9 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -78,6 +78,6 @@ def test_request_factory(self): webhook_item = WebhookItem() webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", None) - webhook_request_actual = '{}\r\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) + 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) From f89b1d562d94f917e29c4d182fb598df1de66623 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 25 Nov 2019 11:53:42 -0800 Subject: [PATCH 119/567] tests and lint passing --- contributing.md | 6 +++++- samples/explore_webhooks.py | 3 --- tableauserverclient/server/endpoint/webhooks_endpoint.py | 1 - test/test_webhook.py | 5 +++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/contributing.md b/contributing.md index 4adebc091..4c7cdef00 100644 --- a/contributing.md +++ b/contributing.md @@ -59,4 +59,8 @@ somewhere. > pip install versioneer > python setup.py build > python setup.py test -> +> + +### before committing +Our CI runs include a python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin +> pycodestyle tableauserverclient test samples diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 64228b6f0..ab94f7195 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -30,8 +30,6 @@ def main(): help='desired logging level (set to error by default)') args = parser.parse_args() - - if args.p is None: password = getpass.getpass("Password: ") else: @@ -80,6 +78,5 @@ def main(): server.webhooks.delete(sample_webhook.id) - if __name__ == '__main__': main() diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 3dc28e04a..4e69974d1 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -61,4 +61,3 @@ def test(self, webhook_id): testOutcome = self.get_request(url) logger.info('Testing webhook (ID: {0} returned {1})'.format(webhook_id, testOutcome)) return testOutcome - diff --git a/test/test_webhook.py b/test/test_webhook.py index e1968495e..819de18ae 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -77,6 +77,7 @@ def test_request_factory(self): webhook_item = WebhookItem() webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", None) - webhook_request_actual = '{}\r\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) + 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) + # windows does /r/n for linebreaks, remove the extra char if it is there + self.assertEqual(webhook_request_expected.replace('\r', ''), webhook_request_actual) From 308705da66d9e25a8c8a3c0ec2dfcf45c761ba56 Mon Sep 17 00:00:00 2001 From: Jorge Fonseca Date: Wed, 4 Dec 2019 19:05:57 +0000 Subject: [PATCH 120/567] Adds description as a read-only attribute of WorkbookItem (#533) --- tableauserverclient/models/workbook_item.py | 20 ++++++++++++++------ test/assets/workbook_get.xml | 4 ++-- test/assets/workbook_get_by_id.xml | 2 +- test/test_workbook.py | 3 +++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index d518f23a4..ce4f43ed5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -22,6 +22,7 @@ def __init__(self, project_id, name=None, show_tabs=False): self._updated_at = None self._views = None self.name = name + self._description = None self.owner_id = None self.project_id = project_id self.show_tabs = show_tabs @@ -52,6 +53,10 @@ def content_url(self): def created_at(self): return self._created_at + @property + def description(self): + return self._description + @property def id(self): return self._id @@ -145,17 +150,17 @@ def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) if workbook_xml is not None: - (_, _, _, _, updated_at, _, show_tabs, + (_, _, _, _, description, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, materialized_views_config) = self._parse_element(workbook_xml, ns) - self._set_values(None, None, None, None, updated_at, + self._set_values(None, None, None, None, description, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, materialized_views_config) return self - def _set_values(self, id, name, content_url, created_at, updated_at, + def _set_values(self, id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, materialized_views_config): if id is not None: @@ -166,6 +171,8 @@ def _set_values(self, id, name, content_url, created_at, updated_at, self._content_url = content_url if created_at: self._created_at = created_at + if description: + self._description = description if updated_at: self._updated_at = updated_at if size: @@ -192,12 +199,12 @@ def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) all_workbook_xml = parsed_response.findall('.//t:workbook', namespaces=ns) for workbook_xml in all_workbook_xml: - (id, name, content_url, created_at, updated_at, size, show_tabs, + (id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, materialized_views_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) - workbook_item._set_values(id, name, content_url, created_at, updated_at, + workbook_item._set_values(id, name, content_url, created_at, description, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, materialized_views_config) all_workbook_items.append(workbook_item) @@ -209,6 +216,7 @@ def _parse_element(workbook_xml, ns): name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) created_at = parse_datetime(workbook_xml.get('createdAt', None)) + description = workbook_xml.get('description', None) updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) size = workbook_xml.get('size', None) @@ -245,7 +253,7 @@ def _parse_element(workbook_xml, ns): if materialized_views_elem is not None: materialized_views_config = parse_materialized_views_config(materialized_views_elem) - return id, name, content_url, created_at, updated_at, size, show_tabs,\ + return id, name, content_url, created_at, description, updated_at, size, show_tabs,\ project_id, project_name, owner_id, tags, views, materialized_views_config diff --git a/test/assets/workbook_get.xml b/test/assets/workbook_get.xml index 6a753f70c..e5fd3967b 100644 --- a/test/assets/workbook_get.xml +++ b/test/assets/workbook_get.xml @@ -2,12 +2,12 @@ - + - + diff --git a/test/assets/workbook_get_by_id.xml b/test/assets/workbook_get_by_id.xml index 13bb76523..1b2fe9120 100644 --- a/test/assets/workbook_get_by_id.xml +++ b/test/assets/workbook_get_by_id.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_workbook.py b/test/test_workbook.py index 714f28941..fac6c49a1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -55,6 +55,7 @@ def test_get(self): self.assertEqual(False, all_workbooks[0].show_tabs) self.assertEqual(1, all_workbooks[0].size) self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) + self.assertEqual('description for Superstore', all_workbooks[0].description) self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[0].project_id) self.assertEqual('default', all_workbooks[0].project_name) @@ -66,6 +67,7 @@ def test_get(self): self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) + self.assertEqual('description for SafariSample', all_workbooks[1].description) self.assertEqual('2016-07-26T20:35:05Z', format_datetime(all_workbooks[1].updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[1].project_id) self.assertEqual('default', all_workbooks[1].project_name) @@ -99,6 +101,7 @@ def test_get_by_id(self): self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + self.assertEqual('description for SafariSample', single_workbook.description) self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_workbook.project_id) self.assertEqual('default', single_workbook.project_name) From 90343ace2b4dcc31fac7a2cf2889670a7c30cf62 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Fri, 13 Dec 2019 12:16:54 -0800 Subject: [PATCH 121/567] Add support for materializeViews as schedule and task type (#542) * Add support for materializeViews as schedule and task type * added task type to the url for get_by_id and run in tasks_endpoint, and added tests * normalized task_type * added a blank line at the line * fixed formatting failures * changed the logic for assert api version for MaterializeViews --- tableauserverclient/models/schedule_item.py | 3 +- tableauserverclient/models/task_item.py | 32 ++++++++--- .../server/endpoint/schedules_endpoint.py | 7 ++- .../server/endpoint/tasks_endpoint.py | 47 ++++++++++++---- tableauserverclient/server/request_factory.py | 23 ++++---- test/assets/tasks_run_now_response.xml | 6 ++ .../tasks_with_materializeviews_task.xml | 18 ++++++ test/test_task.py | 56 ++++++++++++++++++- 8 files changed, 155 insertions(+), 37 deletions(-) create mode 100644 test/assets/tasks_run_now_response.xml create mode 100644 test/assets/tasks_with_materializeviews_task.xml diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 11c403764..18c0516d1 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -11,6 +11,7 @@ class Type: Extract = "Extract" Flow = "Flow" Subscription = "Subscription" + MaterializeViews = "MaterializeViews" class ExecutionOrder: Parallel = "Parallel" @@ -199,7 +200,7 @@ def _parse_interval_item(parsed_response, frequency, ns): # We use fractional hours for the two minute-based intervals. # Need to convert to hours from minutes here if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value / 60) + interval_value = float(interval_value) / 60 return HourlyInterval(start_time, end_time, interval_value) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index d9d7f2cd0..2b08eee05 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,14 +1,23 @@ import xml.etree.ElementTree as ET from .target import Target +from .schedule_item import ScheduleItem +from ..datetime_helpers import parse_datetime class TaskItem(object): - def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, target=None): + class Type: + ExtractRefresh = "extractRefresh" + MaterializeViews = "materializeViews" + + def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, + schedule_item=None, last_run_at=None, target=None): self.id = id_ self.task_type = task_type self.priority = priority self.consecutive_failed_count = consecutive_failed_count self.schedule_id = schedule_id + self.schedule_item = schedule_item + self.last_run_at = last_run_at self.target = target def __repr__(self): @@ -16,10 +25,10 @@ def __repr__(self): "schedule_id}) target({target})>".format(**self.__dict__) @classmethod - def from_response(cls, xml, ns): + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): parsed_response = ET.fromstring(xml) all_tasks_xml = parsed_response.findall( - './/t:task/t:extractRefresh', namespaces=ns) + './/t:task/t:{}'.format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) @@ -27,13 +36,17 @@ def from_response(cls, xml, ns): @classmethod def _parse_element(cls, element, ns): - schedule = None + schedule_id = None + schedule_item = None target = None - schedule_element = element.find('.//t:schedule', namespaces=ns) + last_run_at = None workbook_element = element.find('.//t:workbook', namespaces=ns) datasource_element = element.find('.//t:datasource', namespaces=ns) - if schedule_element is not None: - schedule = schedule_element.get('id', None) + last_run_at_element = element.find('.//t:lastRunAt', namespaces=ns) + + schedule_item_list = ScheduleItem.from_element(element, ns) + if len(schedule_item_list) >= 1: + schedule_item = schedule_item_list[0] # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource @@ -43,9 +56,12 @@ def _parse_element(cls, element, ns): if datasource_element is not None: datasource_id = datasource_element.get('id', None) target = Target(datasource_id, "datasource") + if last_run_at_element is not None: + last_run_at = parse_datetime(last_run_at_element.text) task_type = element.get('type', None) priority = int(element.get('priority', -1)) consecutive_failed_count = int(element.get('consecutiveFailedCount', 0)) id_ = element.get('id', None) - return cls(id_, task_type, priority, consecutive_failed_count, schedule, target) + return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id, + schedule_item, last_run_at, target) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 2de488bdb..ccba83565 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem +from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem import logging import copy from collections import namedtuple @@ -68,12 +68,13 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") - def add_to_schedule(self, schedule_id, workbook=None, datasource=None): + def add_to_schedule(self, schedule_id, workbook=None, datasource=None, + task_type=TaskItem.Type.ExtractRefresh): def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) - add_req = req_factory(id_) + add_req = req_factory(id_, task_type=task_type) response = self.put_request(url, add_req) if response.status_code < 200 or response.status_code >= 300: return AddResponse(result=False, diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 7df5bc0ad..2abe87104 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -9,18 +9,35 @@ class Tasks(Endpoint): @property def baseurl(self): - return "{0}/sites/{1}/tasks/extractRefreshes".format(self.parent_srv.baseurl, - self.parent_srv.site_id) + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, + self.parent_srv.site_id) + + def __normalize_task_type(self, task_type): + """ + The word for extract refresh used in API URL is "extractRefreshes". + It is different than the tag "extractRefresh" used in the request body. + """ + if task_type == TaskItem.Type.ExtractRefresh: + return '{}es'.format(task_type) + else: + return task_type @api(version='2.6') - def get(self, req_options=None): - logger.info('Querying all tasks for the site') - url = self.baseurl + def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + + logger.info('Querying all {} tasks for the site'.format(task_type)) + + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - all_extract_tasks = TaskItem.from_response(server_response.content, self.parent_srv.namespace) - return all_extract_tasks, pagination_item + pagination_item = PaginationItem.from_response(server_response.content, + self.parent_srv.namespace) + all_tasks = TaskItem.from_response(server_response.content, + self.parent_srv.namespace, + task_type) + return all_tasks, pagination_item @api(version='2.6') def get_by_id(self, task_id): @@ -28,7 +45,8 @@ def get_by_id(self, task_id): error = "No Task ID provided" raise ValueError(error) logger.info("Querying a single task by id ({})".format(task_id)) - url = "{}/{}".format(self.baseurl, task_id) + url = "{}/{}/{}".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -38,17 +56,22 @@ def run(self, task_item): error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/runNow".format(self.baseurl, task_item.id) + url = "{0}/{1}/{2}/runNow".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) return server_response.content # Delete 1 task by id @api(version="3.6") - def delete(self, task_id): + def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, task_id) + url = "{0}/{1}/{2}".format(self.baseurl, + self.__normalize_task_type(task_type), task_id) self.delete_request(url) logger.info('Deleted single task (ID: {0})'.format(task_id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 8001a1e6c..90bda676c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -5,7 +5,7 @@ from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import UserItem, GroupItem, PermissionsRule +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule def _add_multipart(parts): @@ -24,6 +24,7 @@ def wrapper(self, *args, **kwargs): xml_request = ET.Element('tsRequest') func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) + return wrapper @@ -311,28 +312,28 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_, type_): + def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ - + - + """ xml_request = ET.Element('tsRequest') task_element = ET.SubElement(xml_request, 'task') - refresh = ET.SubElement(task_element, 'extractRefresh') - workbook = ET.SubElement(refresh, type_) + task = ET.SubElement(task_element, task_type) + workbook = ET.SubElement(task, target_type) workbook.attrib['id'] = id_ return ET.tostring(xml_request) - def add_workbook_req(self, id_): - return self._add_to_req(id_, "workbook") + def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_): - return self._add_to_req(id_, "datasource") + def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "datasource", task_type) class SiteRequest(object): @@ -479,7 +480,7 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config['materialized_views_enabled']\ + if workbook_item.materialized_views_config['materialized_views_enabled'] \ and workbook_item.materialized_views_config['run_materialization_now']: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') diff --git a/test/assets/tasks_run_now_response.xml b/test/assets/tasks_run_now_response.xml new file mode 100644 index 000000000..6a8860cd7 --- /dev/null +++ b/test/assets/tasks_run_now_response.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/tasks_with_materializeviews_task.xml b/test/assets/tasks_with_materializeviews_task.xml new file mode 100644 index 000000000..e586b6bb1 --- /dev/null +++ b/test/assets/tasks_with_materializeviews_task.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + 2019-12-09T20:45:04Z + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index ea22a24c7..a0699bc49 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -2,6 +2,8 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.datetime_helpers import parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -9,18 +11,21 @@ GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") +GET_XML_MATERIALIZEVIEWS_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_materializeviews_task.xml") +GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test") - self.server.version = '3.6' + self.server.version = '3.8' # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = self.server.tasks.baseurl + # default task type is extractRefreshes + self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") def test_get_tasks_with_no_workbook(self): with open(GET_XML_NO_WORKBOOK, "rb") as f: @@ -84,3 +89,50 @@ def test_delete(self): def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.tasks.delete, '') + + def test_get_materializeviews_tasks(self): + with open(GET_XML_MATERIALIZEVIEWS_TASK, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get('{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews), text=response_xml) + all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.MaterializeViews) + + task = all_tasks[0] + self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) + self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) + self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete('{}/{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews, + 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) + self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', + TaskItem.Type.MaterializeViews) + + def test_get_by_id(self): + with open(GET_XML_WITH_WORKBOOK, "rb") as f: + response_xml = f.read().decode("utf-8") + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + with requests_mock.mock() as m: + m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml) + task = self.server.tasks.get_by_id(task_id) + + self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + + def test_run_now(self): + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) + with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml) + job_response_content = self.server.tasks.run(task).decode("utf-8") + + self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content) + self.assertTrue('RefreshExtract' in job_response_content) From 0f6a5bceed172b8fa89645449a416bf560404483 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Thu, 19 Dec 2019 13:21:03 -0800 Subject: [PATCH 122/567] Fix minor bug in request factory (#544) * fix minor bug in request_factory * make materializeNow an optional argument to be consistent with the server side code * factor --- tableauserverclient/server/request_factory.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 90bda676c..3197c6250 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -480,14 +480,15 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config['materialized_views_enabled'] \ - and workbook_item.materialized_views_config['run_materialization_now']: + if workbook_item.materialized_views_config is not None and \ + 'materialized_views_enabled' in workbook_item.materialized_views_config: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config ["materialized_views_enabled"]).lower() - materialized_views_element.attrib['materializeNow'] = str(materialized_views_config - ["run_materialization_now"]).lower() + if "run_materialization_now" in materialized_views_config: + materialized_views_element.attrib['materializeNow'] = str(materialized_views_config + ["run_materialization_now"]).lower() return ET.tostring(xml_request) From d8651a7ec7c9eed125aeb041b64f713a8ecc4e72 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Mon, 30 Dec 2019 10:26:05 -0800 Subject: [PATCH 123/567] Fix minor bug in request factory due to bool(time(0,0))==False in Python 3.4 (#545) * fixing a bug due to bool(time(0,0)) is false * change the null check for end_time at another place --- tableauserverclient/server/request_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3197c6250..4fecdc3eb 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -276,7 +276,7 @@ def create_req(self, schedule_item): schedule_element.attrib['frequency'] = interval_item._frequency frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time: + if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: frequency_element.attrib['end'] = str(interval_item.end_time) if hasattr(interval_item, 'interval') and interval_item.interval: intervals_element = ET.SubElement(frequency_element, 'intervals') @@ -302,7 +302,7 @@ def update_req(self, schedule_item): schedule_element.attrib['frequency'] = interval_item._frequency frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time: + if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: frequency_element.attrib['end'] = str(interval_item.end_time) intervals_element = ET.SubElement(frequency_element, 'intervals') if hasattr(interval_item, 'interval'): From 963d09bb1d78fad5257e0a2fb723580e6f0765db Mon Sep 17 00:00:00 2001 From: Geraldine Zanolli Date: Thu, 9 Jan 2020 15:15:36 -0800 Subject: [PATCH 124/567] Add site-name (#549) * Add site-name * sample-fix * Fix * fix2 * whiteblank --- samples/login.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/login.py b/samples/login.py index aaa21ab25..d3862503d 100644 --- a/samples/login.py +++ b/samples/login.py @@ -22,6 +22,7 @@ def main(): group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--username', '-u', help='username to sign into the server') group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') + parser.add_argument('--sitename', '-S', default=None) args = parser.parse_args() @@ -41,9 +42,9 @@ def main(): else: # Trying to authenticate using personal access tokens. - personal_access_token = getpass.getpass("Personal Access Token: ") + personal_access_token = input("Personal Access Token: ") tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, - personal_access_token=personal_access_token) + personal_access_token=personal_access_token, site_id=args.sitename) with server.auth.sign_in_with_personal_access_token(tableau_auth): print('Logged in successfully') From a5caa7c60e2a47adfdee166fba534256dec43592 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Tue, 14 Jan 2020 10:12:21 -0800 Subject: [PATCH 125/567] Receiving warnings in schedule creation (#550) * fixing a bug due to bool(time(0,0)) is false * add warnings to schedule creation --- tableauserverclient/models/schedule_item.py | 16 ++++++++++++++-- test/assets/schedule_create_weekly.xml | 4 ++++ test/test_schedule.py | 3 +++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 18c0516d1..94823d6c2 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -103,6 +103,10 @@ def state(self, value): def updated_at(self): return self._updated_at + @property + def warnings(self): + return self._warnings + def _parse_common_tags(self, schedule_xml, ns): if not isinstance(schedule_xml, ET.Element): schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=ns) @@ -125,7 +129,7 @@ def _parse_common_tags(self, schedule_xml, ns): return self def _set_values(self, id_, name, state, created_at, updated_at, schedule_type, - next_run_at, end_schedule_at, execution_order, priority, interval_item): + next_run_at, end_schedule_at, execution_order, priority, interval_item, warnings=None): if id_ is not None: self._id = id_ if name: @@ -148,6 +152,8 @@ def _set_values(self, id_, name, state, created_at, updated_at, schedule_type, self._priority = priority if interval_item: self._interval_item = interval_item + if warnings: + self._warnings = warnings @classmethod def from_response(cls, resp, ns): @@ -156,6 +162,11 @@ def from_response(cls, resp, ns): @classmethod def from_element(cls, parsed_response, ns): + all_warning_xml = parsed_response.findall('.//t:warning', namespaces=ns) + warnings = list() if len(all_warning_xml) > 0 else None + for warning_xml in all_warning_xml: + warnings.append(warning_xml.get('message', None)) + all_schedule_items = [] all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=ns) for schedule_xml in all_schedule_xml: @@ -174,7 +185,8 @@ def from_element(cls, parsed_response, ns): end_schedule_at=end_schedule_at, execution_order=None, priority=None, - interval_item=None) + interval_item=None, + warnings=warnings) all_schedule_items.append(schedule_item) return all_schedule_items diff --git a/test/assets/schedule_create_weekly.xml b/test/assets/schedule_create_weekly.xml index 624a56e25..a12a6eace 100644 --- a/test/assets/schedule_create_weekly.xml +++ b/test/assets/schedule_create_weekly.xml @@ -9,4 +9,8 @@ + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index b5aadcbca..310a2b84a 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -153,6 +153,9 @@ def test_create_weekly(self): self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) self.assertEqual(("Monday", "Wednesday", "Friday"), new_schedule.interval_item.interval) + self.assertEqual(2, len(new_schedule.warnings)) + self.assertEqual("warning 1", new_schedule.warnings[0]) + self.assertEqual("warning 2", new_schedule.warnings[1]) def test_create_monthly(self): with open(CREATE_MONTHLY_XML, "rb") as f: From 10381bc7a214673d6d0e59db57699e33531c12fc Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Thu, 16 Jan 2020 17:56:34 -0800 Subject: [PATCH 126/567] Add warnings when adding workbook to schedule (#551) * fixing a bug due to bool(time(0,0)) is false * check warnings in Schedules.add_to_schedule * fixed a typo * removed unused comment * handle the case where a task was indeed created, but there is a warning * remove unnecessary import --- tableauserverclient/models/schedule_item.py | 24 ++++++++++++--- .../server/endpoint/schedules_endpoint.py | 20 ++++++++----- test/assets/schedule_add_datasource.xml | 9 ++++++ test/assets/schedule_add_workbook.xml | 9 ++++++ .../schedule_add_workbook_with_warnings.xml | 12 ++++++++ test/test_schedule.py | 29 ++++++++++++++++--- 6 files changed, 87 insertions(+), 16 deletions(-) create mode 100644 test/assets/schedule_add_datasource.xml create mode 100644 test/assets/schedule_add_workbook.xml create mode 100644 test/assets/schedule_add_workbook_with_warnings.xml diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 94823d6c2..5ece2f8fe 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -162,10 +162,7 @@ def from_response(cls, resp, ns): @classmethod def from_element(cls, parsed_response, ns): - all_warning_xml = parsed_response.findall('.//t:warning', namespaces=ns) - warnings = list() if len(all_warning_xml) > 0 else None - for warning_xml in all_warning_xml: - warnings.append(warning_xml.get('message', None)) + warnings = cls._read_warnings(parsed_response, ns) all_schedule_items = [] all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=ns) @@ -248,3 +245,22 @@ def _parse_element(schedule_xml, ns): return id, name, state, created_at, updated_at, schedule_type, \ next_run_at, end_schedule_at, execution_order, priority, interval_item + + @staticmethod + def parse_add_to_schedule_response(response, ns): + parsed_response = ET.fromstring(response.content) + warnings = ScheduleItem._read_warnings(parsed_response, ns) + all_task_xml = parsed_response.findall('.//t:task', namespaces=ns) + + error = "Status {}: {}".format(response.status_code, response.reason) \ + if response.status_code < 200 or response.status_code >= 300 else None + task_created = len(all_task_xml) > 0 + return error, warnings, task_created + + @staticmethod + def _read_warnings(parsed_response, ns): + all_warning_xml = parsed_response.findall('.//t:warning', namespaces=ns) + warnings = list() if len(all_warning_xml) > 0 else None + for warning_xml in all_warning_xml: + warnings.append(warning_xml.get('message', None)) + return warnings diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index ccba83565..06fb7e408 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,8 +7,8 @@ logger = logging.getLogger('tableau.endpoint.schedules') # Oh to have a first class Result concept in Python... -AddResponse = namedtuple('AddResponse', ('result', 'error')) -OK = AddResponse(result=True, error=None) +AddResponse = namedtuple('AddResponse', ('result', 'error', 'warnings', 'task_created')) +OK = AddResponse(result=True, error=None, warnings=None, task_created=None) class Schedules(Endpoint): @@ -70,17 +70,21 @@ def create(self, schedule_item): @api(version="2.8") def add_to_schedule(self, schedule_id, workbook=None, datasource=None, task_type=TaskItem.Type.ExtractRefresh): - def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) add_req = req_factory(id_, task_type=task_type) response = self.put_request(url, add_req) - if response.status_code < 200 or response.status_code >= 300: - return AddResponse(result=False, - error="Status {}: {}".format(response.status_code, response.reason)) - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) - return OK + + error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response( + response, self.parent_srv.namespace) + if task_created: + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + + if error is not None or warnings is not None: + return AddResponse(result=False, error=error, warnings=warnings, task_created=task_created) + else: + return OK items = [] diff --git a/test/assets/schedule_add_datasource.xml b/test/assets/schedule_add_datasource.xml new file mode 100644 index 000000000..e57d2c8d2 --- /dev/null +++ b/test/assets/schedule_add_datasource.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_add_workbook.xml b/test/assets/schedule_add_workbook.xml new file mode 100644 index 000000000..a6adb005e --- /dev/null +++ b/test/assets/schedule_add_workbook.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_add_workbook_with_warnings.xml b/test/assets/schedule_add_workbook_with_warnings.xml new file mode 100644 index 000000000..0c376d018 --- /dev/null +++ b/test/assets/schedule_add_workbook_with_warnings.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 310a2b84a..b7b047d02 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -14,6 +14,9 @@ CREATE_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_weekly.xml") CREATE_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_monthly.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "schedule_update.xml") +ADD_WORKBOOK_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook.xml") +ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") +ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'datasource_get_by_id.xml') @@ -208,24 +211,42 @@ def test_add_workbook(self): with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") + with open(ADD_WORKBOOK_TO_SCHEDULE, "rb") as f: + add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - # TODO: Replace with real response m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text="OK") + m.put(baseurl + '/foo/workbooks', text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") result = self.server.schedules.add_to_schedule('foo', workbook=workbook) self.assertEqual(0, len(result), "Added properly") + def test_add_workbook_with_warnings(self): + self.server.version = "2.8" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + + with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: + workbook_response = f.read().decode("utf-8") + with open(ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS, "rb") as f: + add_workbook_response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) + m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + workbook = self.server.workbooks.get_by_id("bar") + result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + self.assertEqual(1, len(result), "Not added properly") + self.assertEqual(2, len(result[0].warnings)) + def test_add_datasource(self): self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") + with open(ADD_DATASOURCE_TO_SCHEDULE, "rb") as f: + add_datasource_response = f.read().decode("utf-8") with requests_mock.mock() as m: - # TODO: Replace with real response m.get(self.server.datasources.baseurl + '/bar', text=datasource_response) - m.put(baseurl + '/foo/datasources', text="OK") + m.put(baseurl + '/foo/datasources', text=add_datasource_response) datasource = self.server.datasources.get_by_id("bar") result = self.server.schedules.add_to_schedule('foo', datasource=datasource) self.assertEqual(0, len(result), "Added properly") From f4f8530354788fbc48fdbe99db68e6d7491732c2 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 3 Feb 2020 14:36:05 -0800 Subject: [PATCH 127/567] change version requirement for urllib3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cea7260a0..82c611f0f 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ setup_requires=pytest_runner, install_requires=[ 'requests>=2.11,<3.0', - 'urllib3==1.24.3' + 'urllib3>=1.24.3,<2.0' ], tests_require=[ 'requests-mock>=1.0,<2.0', From 15ee44b42e794591470941f02fbbc96229a14823 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 3 Feb 2020 15:59:28 -0800 Subject: [PATCH 128/567] update expected value in test to be url-encoded --- test/test_sort.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_sort.py b/test/test_sort.py index 5eef07a9d..40936d835 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -57,7 +57,7 @@ def test_filter_in(self): request_object=opts, auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='text/xml') - self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:[stocks,market]') + self.assertEqual(resp.request.query, 'pagenumber=13&pagesize=13&filter=tags:in:%5bstocks,market%5d') def test_sort_asc(self): with requests_mock.mock() as m: From 0b3dc62609341f38d43854e232c4230abfb06db8 Mon Sep 17 00:00:00 2001 From: Kacper Wolkiewicz <45897171+wolkiewiczk@users.noreply.github.com> Date: Wed, 12 Feb 2020 20:10:31 +0100 Subject: [PATCH 129/567] Added functionality to update projects' parent_id (#560) --- tableauserverclient/server/request_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4fecdc3eb..89233d8be 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -249,6 +249,8 @@ def update_req(self, project_item): project_element.attrib['description'] = project_item.description if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions + if project_item.parent_id: + project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) def create_req(self, project_item): From 300307d5dd7b29d122a191964ae15e090b701f86 Mon Sep 17 00:00:00 2001 From: Kacper Wolkiewicz <45897171+wolkiewiczk@users.noreply.github.com> Date: Fri, 14 Feb 2020 18:13:15 +0100 Subject: [PATCH 130/567] Enabled moving projects too the root. (#567) --- tableauserverclient/server/request_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 89233d8be..b447b072b 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -249,7 +249,7 @@ def update_req(self, project_item): project_element.attrib['description'] = project_item.description if project_item.content_permissions: project_element.attrib['contentPermissions'] = project_item.content_permissions - if project_item.parent_id: + if project_item.parent_id is not None: project_element.attrib['parentProjectId'] = project_item.parent_id return ET.tostring(xml_request) From a0fb11488797ede466aa113943f0624838596c71 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 21 Feb 2020 15:13:42 -0800 Subject: [PATCH 131/567] Prepares branch for a new release (#572) * Added a way to handle non-xml errors (#515) * Added Webhooks endpoints for create, delete, get, list, and test (#523, #532) * Added delete method in the tasks endpoint (#524) * Added description attribute to WorkbookItem (#533) * Added support for materializeViews as schedule and task types (#542) * Added warnings to schedules (#550, #551) * Added ability to update parent_id attribute of projects (#560, #567) * Improved filename behavior for download endpoints (#517) * Improved logging (#508) * Fixed runtime error in permissions endpoint (#513) * Fixed move_workbook_sites sample (#503) * Fixed project permissions endpoints (#527) * Fixed login.py sample to accept site name (#549) --- CHANGELOG.md | 16 ++++++++++++++++ CONTRIBUTORS.md | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2881e514..6189ab78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.10 (21 Feb 2020) + +* Added a way to handle non-xml errors (#515) +* Added Webhooks endpoints for create, delete, get, list, and test (#523, #532) +* Added delete method in the tasks endpoint (#524) +* Added description attribute to WorkbookItem (#533) +* Added support for materializeViews as schedule and task types (#542) +* Added warnings to schedules (#550, #551) +* Added ability to update parent_id attribute of projects (#560, #567) +* Improved filename behavior for download endpoints (#517) +* Improved logging (#508) +* Fixed runtime error in permissions endpoint (#513) +* Fixed move_workbook_sites sample (#503) +* Fixed project permissions endpoints (#527) +* Fixed login.py sample to accept site name (#549) + ## 0.9 (4 Oct 2019) * Added Metadata API endpoints (#431) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8022c5f49..a23213598 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -28,6 +28,12 @@ The following people have contributed to this project to make it possible, and w * [Christian Oliff](https://github.com/coliff) * [Albin Antony](https://github.com/user9747) * [prae04](https://github.com/prae04) +* [Martin Peters](https://github.com/martinbpeters) +* [Sherman K](https://github.com/shrmnk) +* [Jorge Fonseca](https://github.com/JorgeFonseca) +* [Kacper Wolkiewicz](https://github.com/wolkiewiczk) +* [Dahai Guo](https://github.com/guodah) +* [Geraldine Zanolli](https://github.com/illonage) ## Core Team @@ -41,4 +47,3 @@ The following people have contributed to this project to make it possible, and w * [Priya Reguraman](https://github.com/preguraman) * [Jac Fitzgerald](https://github.com/jacalata) * [Dan Zucker](https://github.com/dzucker-tab) -* [Irwin Dolobowsky](https://github.com/irwando) From c4b36f60cfe7938f2a51ae074b597e39dc508abd Mon Sep 17 00:00:00 2001 From: Russell Hay Date: Mon, 24 Feb 2020 13:48:51 -0800 Subject: [PATCH 132/567] Adds long description to setup. (#574) This will not work under python2 which is fine since 2 is EOL --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 82c611f0f..58fa03311 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,11 @@ except ImportError: from distutils.core import setup +from os import path +this_directory = path.abspath(path.dirname(__file__)) +with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + # Only install pytest and runner when test command is run # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) @@ -22,6 +27,8 @@ 'tableauserverclient.server.endpoint'], license='MIT', description='A Python module for working with the Tableau Server REST API.', + long_description=long_description, + long_description_content_type='text/markdown', test_suite='test', setup_requires=pytest_runner, install_requires=[ From 4122e9cc0789c9455c108be33118f586ffecad70 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Tue, 3 Mar 2020 09:31:31 -0800 Subject: [PATCH 133/567] Replacing the feature name "materialized views" with "data acceleration" (#576) * fixing a bug due to bool(time(0,0)) is false * replace materialized views with data acceleration * made changes in tests * fixed a unit test * restore deleted lines by mistake * replace materialize_workbooks.py with accelerate_workbooks.py * undo a fix to test_sort.py * format changes * another format change * format change * remove accelerate_workbooks.py --- samples/materialize_workbooks.py | 342 ------------------ .../models/property_decorators.py | 8 +- tableauserverclient/models/schedule_item.py | 2 +- tableauserverclient/models/site_item.py | 32 +- tableauserverclient/models/task_item.py | 2 +- tableauserverclient/models/webhook_item.py | 2 +- tableauserverclient/models/workbook_item.py | 66 ++-- .../server/endpoint/tasks_endpoint.py | 4 +- tableauserverclient/server/request_factory.py | 22 +- .../schedule_add_workbook_with_warnings.xml | 4 +- ...l => tasks_with_dataacceleration_task.xml} | 6 +- test/test_site.py | 4 +- test/test_task.py | 12 +- 13 files changed, 82 insertions(+), 424 deletions(-) delete mode 100644 samples/materialize_workbooks.py rename test/assets/{tasks_with_materializeviews_task.xml => tasks_with_dataacceleration_task.xml} (79%) diff --git a/samples/materialize_workbooks.py b/samples/materialize_workbooks.py deleted file mode 100644 index 696dda4b7..000000000 --- a/samples/materialize_workbooks.py +++ /dev/null @@ -1,342 +0,0 @@ -import argparse -import getpass -import logging -import os -import tableauserverclient as TSC -from collections import defaultdict - - -def main(): - parser = argparse.ArgumentParser(description='Materialized views settings for sites/workbooks.') - parser.add_argument('--server', '-s', required=True, help='Tableau server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--password', '-p', required=False, help='password to sign into server') - parser.add_argument('--mode', '-m', required=False, choices=['disable', 'enable', 'enable_all', 'enable_selective'], - help='enable/disable materialized views for sites/workbooks') - parser.add_argument('--status', '-st', required=False, action='store_true', - help='show materialized views enabled sites/workbooks') - parser.add_argument('--site-id', '-si', required=False, - help='set to Default site by default') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') - parser.add_argument('--type', '-t', required=False, choices=['site', 'workbook', 'project_name', 'project_path'], - help='type of content you want to update materialized views settings on') - parser.add_argument('--path-list', '-pl', required=False, help='path to a list of workbook paths') - parser.add_argument('--name-list', '-nl', required=False, help='path to a list of workbook names') - parser.add_argument('--project-name', '-pn', required=False, help='name of the project') - parser.add_argument('--project-path', '-pp', required=False, help="path of the project") - parser.add_argument('--materialize-now', '-mn', required=False, action='store_true', - help='create materialized views for workbooks immediately') - - args = parser.parse_args() - - if args.password: - password = args.password - else: - password = getpass.getpass("Password: ") - - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - # site content url is the TSC term for site id - site_content_url = args.site_id if args.site_id is not None else "" - - if not assert_options_valid(args): - return - - materialized_views_config = create_materialized_views_config(args) - - # enable/disable materialized views for site - if args.type == 'site': - if not update_site(args, password, site_content_url): - return - - # enable/disable materialized views for workbook - # works only when the site the workbooks belong to are enabled too - elif args.type == 'workbook': - if not update_workbook(args, materialized_views_config, password, site_content_url): - return - - # enable/disable materialized views for project by project name - # will show possible projects when project name is not unique - elif args.type == 'project_name': - if not update_project_by_name(args, materialized_views_config, password, site_content_url): - return - - # enable/disable materialized views for project by project path, for example: project1/project2 - elif args.type == 'project_path': - if not update_project_by_path(args, materialized_views_config, password, site_content_url): - return - - # show enabled sites and workbooks - if args.status: - show_materialized_views_status(args, password, site_content_url) - - -def find_project_path(project, all_projects, path): - # project stores the id of it's parent - # this method is to run recursively to find the path from root project to given project - path = project.name if len(path) == 0 else project.name + '/' + path - - if project.parent_id is None: - return path - else: - return find_project_path(all_projects[project.parent_id], all_projects, path) - - -def get_project_paths(server, projects): - # most likely user won't have too many projects so we store them in a dict to search - all_projects = {project.id: project for project in TSC.Pager(server.projects)} - - result = dict() - for project in projects: - result[find_project_path(project, all_projects, "")] = project - return result - - -def print_paths(paths): - for path in paths.keys(): - print(path) - - -def show_materialized_views_status(args, password, site_content_url): - tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server, use_server_version=True) - enabled_sites = set() - with server.auth.sign_in(tableau_auth): - # For server admin, this will prints all the materialized views enabled sites - # For other users, this only prints the status of the site they belong to - print("Materialized views is enabled on sites:") - # only server admins can get all the sites in the server - # other users can only get the site they are in - for site in TSC.Pager(server.sites): - if site.materialized_views_mode != "disable": - enabled_sites.add(site) - print("Site name: {}".format(site.name)) - print('\n') - - print("Materialized views is enabled on workbooks:") - # Individual workbooks can be enabled only when the sites they belong to are enabled too - for site in enabled_sites: - site_auth = TSC.TableauAuth(args.username, password, site.content_url) - with server.auth.sign_in(site_auth): - for workbook in TSC.Pager(server.workbooks): - if workbook.materialized_views_config['materialized_views_enabled']: - print("Workbook: {} from site: {}".format(workbook.name, site.name)) - - -def update_project_by_path(args, materialized_views_config, password, site_content_url): - if args.project_path is None: - print("Use --project_path to specify the path of the project") - return False - tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) - server = TSC.Server(args.server, use_server_version=True) - project_name = args.project_path.split('/')[-1] - with server.auth.sign_in(tableau_auth): - if not assert_site_enabled_for_materialized_views(server, site_content_url): - return False - projects = [project for project in TSC.Pager(server.projects) if project.name == project_name] - if not assert_project_valid(args.project_path, projects): - return False - - possible_paths = get_project_paths(server, projects) - update_project(possible_paths[args.project_path], server, materialized_views_config) - return True - - -def update_project_by_name(args, materialized_views_config, password, site_content_url): - if args.project_name is None: - print("Use --project-name to specify the name of the project") - return False - tableau_auth = TSC.TableauAuth(args.username, password, site_content_url) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - if not assert_site_enabled_for_materialized_views(server, site_content_url): - return False - # get all projects with given name - projects = [project for project in TSC.Pager(server.projects) if project.name == args.project_name] - if not assert_project_valid(args.project_name, projects): - return False - - if len(projects) > 1: - possible_paths = get_project_paths(server, projects) - print("Project name is not unique, use '--project_path '") - print("Possible project paths:") - print_paths(possible_paths) - print('\n') - return False - else: - update_project(projects[0], server, materialized_views_config) - return True - - -def update_project(project, server, materialized_views_config): - all_projects = list(TSC.Pager(server.projects)) - project_ids = find_project_ids_to_update(all_projects, project) - for workbook in TSC.Pager(server.workbooks): - if workbook.project_id in project_ids: - workbook.materialized_views_config = materialized_views_config - server.workbooks.update(workbook) - - print("Updated materialized views settings for project: {}".format(project.name)) - print('\n') - - -def find_project_ids_to_update(all_projects, project): - projects_to_update = [] - find_projects_to_update(project, all_projects, projects_to_update) - return set([project_to_update.id for project_to_update in projects_to_update]) - - -def parse_workbook_path(file_path): - # parse the list of project path of workbooks - workbook_paths = sanitize_workbook_list(file_path, "path") - - workbook_path_mapping = defaultdict(list) - for workbook_path in workbook_paths: - workbook_project = workbook_path.rstrip().split('/') - workbook_path_mapping[workbook_project[-1]].append('/'.join(workbook_project[:-1])) - return workbook_path_mapping - - -def update_workbook(args, materialized_views_config, password, site_content_url): - if args.path_list is None and args.name_list is None: - print("Use '--path-list ' or '--name-list ' to specify the path of a list of workbooks") - print('\n') - return False - tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - if not assert_site_enabled_for_materialized_views(server, site_content_url): - return False - if args.path_list is not None: - workbook_path_mapping = parse_workbook_path(args.path_list) - all_projects = {project.id: project for project in TSC.Pager(server.projects)} - update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping) - elif args.name_list is not None: - update_workbooks_by_names(args.name_list, server, materialized_views_config) - return True - - -def update_workbooks_by_paths(all_projects, materialized_views_config, server, workbook_path_mapping): - for workbook_name, workbook_paths in workbook_path_mapping.items(): - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - workbook_name)) - workbooks = list(TSC.Pager(server.workbooks, req_option)) - all_paths = set(workbook_paths[:]) - for workbook in workbooks: - path = find_project_path(all_projects[workbook.project_id], all_projects, "") - if path in workbook_paths: - all_paths.remove(path) - workbook.materialized_views_config = materialized_views_config - server.workbooks.update(workbook) - print("Updated materialized views settings for workbook: {}".format(path + '/' + workbook.name)) - - for path in all_paths: - print("Cannot find workbook path: {}, each line should only contain one workbook path" - .format(path + '/' + workbook_name)) - print('\n') - - -def update_workbooks_by_names(name_list, server, materialized_views_config): - workbook_names = sanitize_workbook_list(name_list, "name") - for workbook_name in workbook_names: - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - workbook_name.rstrip())) - workbooks = list(TSC.Pager(server.workbooks, req_option)) - if len(workbooks) == 0: - print("Cannot find workbook name: {}, each line should only contain one workbook name" - .format(workbook_name)) - for workbook in workbooks: - workbook.materialized_views_config = materialized_views_config - server.workbooks.update(workbook) - print("Updated materialized views settings for workbook: {}".format(workbook.name)) - print('\n') - - -def update_site(args, password, site_content_url): - if not assert_site_options_valid(args): - return False - tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_content_url) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - site_to_update = server.sites.get_by_content_url(site_content_url) - site_to_update.materialized_views_mode = args.mode - - server.sites.update(site_to_update) - print("Updated materialized views settings for site: {}".format(site_to_update.name)) - print('\n') - return True - - -def create_materialized_views_config(args): - materialized_views_config = dict() - materialized_views_config['materialized_views_enabled'] = args.mode == "enable" - materialized_views_config['run_materialization_now'] = True if args.materialize_now else False - return materialized_views_config - - -def assert_site_options_valid(args): - if args.materialize_now: - print('"--materialize-now" only applies to workbook/project type') - return False - if args.mode == 'enable': - print('For site type please choose from "disable", "enable_all", or "enable_selective"') - return False - return True - - -def assert_options_valid(args): - if args.type != "site" and args.mode in ("enable_all", "enable_selective"): - print('"enable_all" and "enable_selective" do not apply to workbook/project type') - return False - if (args.type is None) != (args.mode is None): - print("Use '--type --mode ' to update materialized views settings.") - return False - return True - - -def assert_site_enabled_for_materialized_views(server, site_content_url): - parent_site = server.sites.get_by_content_url(site_content_url) - if parent_site.materialized_views_mode == "disable": - print('Cannot update workbook/project because site is disabled for materialized views') - return False - return True - - -def assert_project_valid(project_name, projects): - if len(projects) == 0: - print("Cannot find project: {}".format(project_name)) - return False - return True - - -def find_projects_to_update(project, all_projects, projects_to_update): - # Use recursion to find all the sub-projects and enable/disable the workbooks in them - projects_to_update.append(project) - children_projects = [child for child in all_projects if child.parent_id == project.id] - if len(children_projects) == 0: - return - - for child in children_projects: - find_projects_to_update(child, all_projects, projects_to_update) - - -def sanitize_workbook_list(file_name, file_type): - if not os.path.isfile(file_name): - print("Invalid file name '{}'".format(file_name)) - return [] - file_list = open(file_name, "r") - - if file_type == "name": - return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] - if file_type == "path": - return [workbook.rstrip() for workbook in file_list if not workbook.isspace()] - - -if __name__ == "__main__": - main() diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index a4ef0ef3f..d893305f7 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -138,16 +138,16 @@ def wrapper(self, value): return wrapper -def property_is_materialized_views_config(func): +def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) - if len(value) != 2 or not all(attr in value.keys() for attr in ('materialized_views_enabled', - 'run_materialization_now')): + if len(value) != 2 or not all(attr in value.keys() for attr in ('acceleration_enabled', + 'accelerate_now')): error = "{} should have 2 keys ".format(func.__name__) - error += "'materialized_views_enabled' and 'run_materialization_now'" + error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 5ece2f8fe..c93ffe922 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -11,7 +11,7 @@ class Type: Extract = "Extract" Flow = "Flow" Subscription = "Subscription" - MaterializeViews = "MaterializeViews" + DataAcceleration = "DataAcceleration" class ExecutionOrder: Parallel = "Parallel" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 238332597..1ba854e72 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,7 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, materialized_views_mode=None, flows_enabled=None, cataloging_enabled=None): + revision_limit=None, data_acceleration_mode=None, flows_enabled=None, cataloging_enabled=None): self._admin_mode = None self._id = None self._num_users = None @@ -33,7 +33,7 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.revision_history_enabled = revision_history_enabled self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode - self.materialized_views_mode = materialized_views_mode + self.data_acceleration_mode = data_acceleration_mode self.cataloging_enabled = cataloging_enabled self.flows_enabled = flows_enabled @@ -127,12 +127,12 @@ def subscribe_others_enabled(self, value): self._subscribe_others_enabled = value @property - def materialized_views_mode(self): - return self._materialized_views_mode + def data_acceleration_mode(self): + return self._data_acceleration_mode - @materialized_views_mode.setter - def materialized_views_mode(self, value): - self._materialized_views_mode = value + @data_acceleration_mode.setter + def data_acceleration_mode(self, value): + self._data_acceleration_mode = value @property def cataloging_enabled(self): @@ -160,17 +160,17 @@ def _parse_common_tags(self, site_xml, ns): (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode, cataloging_enabled, flows_enabled) = self._parse_element(site_xml, ns) + data_acceleration_mode, cataloging_enabled, flows_enabled) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode, cataloging_enabled, + revision_limit, num_users, storage, data_acceleration_mode, cataloging_enabled, flows_enabled) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, materialized_views_mode, + user_quota, storage_quota, revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled): if id is not None: self._id = id @@ -200,8 +200,8 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self._num_users = num_users if storage: self._storage = storage - if materialized_views_mode: - self._materialized_views_mode = materialized_views_mode + if data_acceleration_mode: + self._data_acceleration_mode = data_acceleration_mode if flows_enabled is not None: self.flows_enabled = flows_enabled if cataloging_enabled is not None: @@ -215,14 +215,14 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, materialized_views_mode, flows_enabled, + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - materialized_views_mode, flows_enabled, cataloging_enabled) + data_acceleration_mode, flows_enabled, cataloging_enabled) all_site_items.append(site_item) return all_site_items @@ -257,14 +257,14 @@ def _parse_element(site_xml, ns): num_users = usage_elem.get('numUsers', None) storage = usage_elem.get('storage', None) - materialized_views_mode = site_xml.get('materializedViewsMode', '') + data_acceleration_mode = site_xml.get('dataAccelerationMode', '') flows_enabled = string_to_bool(site_xml.get('flowsEnabled', '')) cataloging_enabled = string_to_bool(site_xml.get('catalogingEnabled', '')) return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, materialized_views_mode, flows_enabled, cataloging_enabled + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 2b08eee05..780412af9 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -7,7 +7,7 @@ class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" - MaterializeViews = "materializeViews" + DataAcceleration = "dataAcceleration" def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, schedule_item=None, last_run_at=None, target=None): diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 4b1a350ee..90fdd4ba2 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ 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 .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config from .tag_item import TagItem from .view_item import ViewItem from .permissions_item import PermissionsRule diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ce4f43ed5..75acb5b15 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,6 @@ 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 .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config from .tag_item import TagItem from .view_item import ViewItem from .permissions_item import PermissionsRule @@ -27,8 +27,8 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() - self.materialized_views_config = {'materialized_views_enabled': None, - 'run_materialization_now': None} + self.data_acceleration_config = {'acceleration_enabled': None, + 'accelerate_now': None} self._permissions = None @property @@ -123,13 +123,13 @@ def views(self): return self._views @property - def materialized_views_config(self): - return self._materialized_views_config + def data_acceleration_config(self): + return self._data_acceleration_config - @materialized_views_config.setter - @property_is_materialized_views_config - def materialized_views_config(self, value): - self._materialized_views_config = value + @data_acceleration_config.setter + @property_is_data_acceleration_config + def data_acceleration_config(self, value): + self._data_acceleration_config = value def _set_connections(self, connections): self._connections = connections @@ -152,17 +152,17 @@ def _parse_common_tags(self, workbook_xml, ns): if workbook_xml is not None: (_, _, _, _, description, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, - materialized_views_config) = self._parse_element(workbook_xml, ns) + data_acceleration_config) = self._parse_element(workbook_xml, ns) self._set_values(None, None, None, None, description, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, - materialized_views_config) + data_acceleration_config) return self def _set_values(self, id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, - materialized_views_config): + data_acceleration_config): if id is not None: self._id = id if name: @@ -190,8 +190,8 @@ def _set_values(self, id, name, content_url, created_at, description, updated_at self._initial_tags = copy.copy(tags) if views: self._views = views - if materialized_views_config is not None: - self.materialized_views_config = materialized_views_config + if data_acceleration_config is not None: + self.data_acceleration_config = data_acceleration_config @classmethod def from_response(cls, resp, ns): @@ -201,12 +201,12 @@ def from_response(cls, resp, ns): for workbook_xml in all_workbook_xml: (id, name, content_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, - materialized_views_config) = cls._parse_element(workbook_xml, ns) + data_acceleration_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) workbook_item._set_values(id, name, content_url, created_at, description, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, - materialized_views_config) + data_acceleration_config) all_workbook_items.append(workbook_item) return all_workbook_items @@ -248,29 +248,29 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) - materialized_views_config = {'materialized_views_enabled': None, 'run_materialization_now': None} - materialized_views_elem = workbook_xml.find('.//t:materializedViewsEnablementConfig', namespaces=ns) - if materialized_views_elem is not None: - materialized_views_config = parse_materialized_views_config(materialized_views_elem) + data_acceleration_config = {'acceleration_enabled': None, 'accelerate_now': None} + data_acceleration_elem = workbook_xml.find('.//t:dataAccelerationConfig', namespaces=ns) + if data_acceleration_elem is not None: + data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) - return id, name, content_url, created_at, description, updated_at, size, show_tabs,\ - project_id, project_name, owner_id, tags, views, materialized_views_config + return id, name, content_url, created_at, description, updated_at, size, show_tabs, \ + project_id, project_name, owner_id, tags, views, data_acceleration_config -def parse_materialized_views_config(materialized_views_elem): - materialized_views_config = dict() +def parse_data_acceleration_config(data_acceleration_elem): + data_acceleration_config = dict() - materialized_views_enabled = materialized_views_elem.get('materializedViewsEnabled', None) - if materialized_views_enabled is not None: - materialized_views_enabled = string_to_bool(materialized_views_enabled) + acceleration_enabled = data_acceleration_elem.get('accelerationEnabled', None) + if acceleration_enabled is not None: + acceleration_enabled = string_to_bool(acceleration_enabled) - run_materialization_now = materialized_views_elem.get('runMaterializationNow', None) - if run_materialization_now is not None: - run_materialization_now = string_to_bool(run_materialization_now) + accelerate_now = data_acceleration_elem.get('accelerateNow', None) + if accelerate_now is not None: + accelerate_now = string_to_bool(accelerate_now) - materialized_views_config['materialized_views_enabled'] = materialized_views_enabled - materialized_views_config['run_materialization_now'] = run_materialization_now - return materialized_views_config + data_acceleration_config['acceleration_enabled'] = acceleration_enabled + data_acceleration_config['accelerate_now'] = accelerate_now + return data_acceleration_config # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 2abe87104..d08209769 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -24,7 +24,7 @@ def __normalize_task_type(self, task_type): @api(version='2.6') def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): - if task_type == TaskItem.Type.MaterializeViews: + if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8") logger.info('Querying all {} tasks for the site'.format(task_type)) @@ -65,7 +65,7 @@ def run(self, task_item): # Delete 1 task by id @api(version="3.6") def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): - if task_type == TaskItem.Type.MaterializeViews: + if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8") if not task_id: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b447b072b..87529b84f 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -362,8 +362,8 @@ def update_req(self, site_item): site_element.attrib['revisionLimit'] = str(site_item.revision_limit) if site_item.subscribe_others_enabled: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() - if site_item.materialized_views_mode is not None: - site_element.attrib['materializedViewsMode'] = str(site_item.materialized_views_mode).lower() + if site_item.data_acceleration_mode is not None: + site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() if site_item.flows_enabled is not None: site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() if site_item.cataloging_enabled is not None: @@ -482,15 +482,15 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config is not None and \ - 'materialized_views_enabled' in workbook_item.materialized_views_config: - materialized_views_config = workbook_item.materialized_views_config - materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') - materialized_views_element.attrib['materializedViewsEnabled'] = str(materialized_views_config - ["materialized_views_enabled"]).lower() - if "run_materialization_now" in materialized_views_config: - materialized_views_element.attrib['materializeNow'] = str(materialized_views_config - ["run_materialization_now"]).lower() + if workbook_item.data_acceleration_config is not None and \ + 'acceleration_enabled' in workbook_item.data_acceleration_config: + data_acceleration_config = workbook_item.data_acceleration_config + data_acceleration_element = ET.SubElement(workbook_element, 'dataAccelerationConfig') + data_acceleration_element.attrib['accelerationEnabled'] = str(data_acceleration_config + ["acceleration_enabled"]).lower() + if "accelerate_now" in data_acceleration_config: + data_acceleration_element.attrib['accelerateNow'] = str(data_acceleration_config + ["accelerate_now"]).lower() return ET.tostring(xml_request) diff --git a/test/assets/schedule_add_workbook_with_warnings.xml b/test/assets/schedule_add_workbook_with_warnings.xml index 0c376d018..1eac2ceef 100644 --- a/test/assets/schedule_add_workbook_with_warnings.xml +++ b/test/assets/schedule_add_workbook_with_warnings.xml @@ -1,9 +1,9 @@ - + - + diff --git a/test/assets/tasks_with_materializeviews_task.xml b/test/assets/tasks_with_dataacceleration_task.xml similarity index 79% rename from test/assets/tasks_with_materializeviews_task.xml rename to test/assets/tasks_with_dataacceleration_task.xml index e586b6bb1..cbe837405 100644 --- a/test/assets/tasks_with_materializeviews_task.xml +++ b/test/assets/tasks_with_dataacceleration_task.xml @@ -2,8 +2,8 @@ - - + + @@ -12,7 +12,7 @@ 2019-12-09T20:45:04Z - + \ No newline at end of file diff --git a/test/test_site.py b/test/test_site.py index 8283a7bdd..09063b861 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -92,7 +92,7 @@ def test_update(self): admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, disable_subscriptions=True, revision_history_enabled=False, - materialized_views_mode='disable') + data_acceleration_mode='disable') single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) @@ -105,7 +105,7 @@ def test_update(self): self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) self.assertEqual(15, single_site.user_quota) - self.assertEqual('disable', single_site.materialized_views_mode) + self.assertEqual('disable', single_site.data_acceleration_mode) self.assertEqual(True, single_site.flows_enabled) self.assertEqual(True, single_site.cataloging_enabled) diff --git a/test/test_task.py b/test/test_task.py index a0699bc49..cf7879305 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -11,7 +11,7 @@ GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") -GET_XML_MATERIALIZEVIEWS_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_materializeviews_task.xml") +GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") @@ -91,12 +91,12 @@ def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.tasks.delete, '') def test_get_materializeviews_tasks(self): - with open(GET_XML_MATERIALIZEVIEWS_TASK, "rb") as f: + with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get('{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.MaterializeViews), text=response_xml) - all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.MaterializeViews) + self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) @@ -108,10 +108,10 @@ def test_get_materializeviews_tasks(self): def test_delete(self): with requests_mock.mock() as m: m.delete('{}/{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.MaterializeViews, + self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', - TaskItem.Type.MaterializeViews) + TaskItem.Type.DataAcceleration) def test_get_by_id(self): with open(GET_XML_WITH_WORKBOOK, "rb") as f: From 476ca563e2db66027a68dac9ce847a22bb8edde0 Mon Sep 17 00:00:00 2001 From: sfarr15 <34426623+sfarr15@users.noreply.github.com> Date: Thu, 26 Mar 2020 11:59:04 +0100 Subject: [PATCH 134/567] Unspecified PDF request option This is the only pagetype not available via tableau api for PDF. The 'Unspecified' pagetype is available for download using tableau online and desktop so I really don't see an issue adding this. Can this kindly be amended as the workaround for this requires downloading an image and converting to pdf which results in poorer quality and larger data size. --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 7e1e6a808..9beea704d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -141,6 +141,7 @@ class PageType: Note = "note" Quarto = "quarto" Tabloid = "tabloid" + Unspecified = "unspecified" class Orientation: Portrait = "portrait" From dd0fcd5a392520c76d31f2201d6e1944bb04aae4 Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Thu, 26 Mar 2020 10:38:51 -0700 Subject: [PATCH 135/567] Add additional fields to data acceleration config (#588) * fixing a bug due to bool(time(0,0)) is false * replace materialized views with data acceleration * made changes in tests * fixed a unit test * restore deleted lines by mistake * replace materialize_workbooks.py with accelerate_workbooks.py * undo a fix to test_sort.py * format changes * another format change * format change * remove accelerate_workbooks.py * adding additional fields to data_acceleration_config Co-authored-by: dguo --- tableauserverclient/models/property_decorators.py | 6 ++++-- tableauserverclient/models/workbook_item.py | 15 +++++++++++++-- tableauserverclient/server/request_options.py | 1 + test/assets/workbook_update.xml | 2 +- test/test_workbook.py | 10 ++++++---- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index d893305f7..2a47c889a 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -144,8 +144,10 @@ def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) - if len(value) != 2 or not all(attr in value.keys() for attr in ('acceleration_enabled', - 'accelerate_now')): + if len(value) != 4 or not all(attr in value.keys() for attr in ('acceleration_enabled', + 'accelerate_now', + 'last_updated_at', + 'acceleration_status')): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 75acb5b15..a7decd41f 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -28,7 +28,9 @@ def __init__(self, project_id, name=None, show_tabs=False): self.show_tabs = show_tabs self.tags = set() self.data_acceleration_config = {'acceleration_enabled': None, - 'accelerate_now': None} + 'accelerate_now': None, + 'last_updated_at': None, + 'acceleration_status': None} self._permissions = None @property @@ -248,7 +250,8 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) - data_acceleration_config = {'acceleration_enabled': None, 'accelerate_now': None} + data_acceleration_config = {'acceleration_enabled': None, 'accelerate_now': None, + 'last_updated_at': None, 'acceleration_status': None} data_acceleration_elem = workbook_xml.find('.//t:dataAccelerationConfig', namespaces=ns) if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) @@ -268,8 +271,16 @@ def parse_data_acceleration_config(data_acceleration_elem): if accelerate_now is not None: accelerate_now = string_to_bool(accelerate_now) + last_updated_at = data_acceleration_elem.get('lastUpdatedAt', None) + if last_updated_at is not None: + last_updated_at = parse_datetime(last_updated_at) + + acceleration_status = data_acceleration_elem.get('accelerationStatus', None) + data_acceleration_config['acceleration_enabled'] = acceleration_enabled data_acceleration_config['accelerate_now'] = accelerate_now + data_acceleration_config['last_updated_at'] = last_updated_at + data_acceleration_config['acceleration_status'] = acceleration_status return data_acceleration_config diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 7e1e6a808..af2ed27de 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -11,6 +11,7 @@ class Operator: LessThan = 'lt' LessThanOrEqual = 'lte' In = 'in' + Has = 'has' class Field: Args = 'args' diff --git a/test/assets/workbook_update.xml b/test/assets/workbook_update.xml index 7a72759d8..6e5d36105 100644 --- a/test/assets/workbook_update.xml +++ b/test/assets/workbook_update.xml @@ -4,6 +4,6 @@ - + \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index fac6c49a1..06019cfac 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -131,8 +131,10 @@ def test_update(self): single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_workbook.name = 'renamedWorkbook' - single_workbook.materialized_views_config = {'materialized_views_enabled': True, - 'run_materialization_now': False} + single_workbook.data_acceleration_config = {'acceleration_enabled': True, + 'accelerate_now': False, + 'last_updated_at': None, + 'acceleration_status': None} single_workbook = self.server.workbooks.update(single_workbook) self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) @@ -140,8 +142,8 @@ def test_update(self): self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) self.assertEqual('renamedWorkbook', single_workbook.name) - self.assertEqual(True, single_workbook.materialized_views_config['materialized_views_enabled']) - self.assertEqual(False, single_workbook.materialized_views_config['run_materialization_now']) + self.assertEqual(True, single_workbook.data_acceleration_config['acceleration_enabled']) + self.assertEqual(False, single_workbook.data_acceleration_config['accelerate_now']) def test_update_missing_id(self): single_workbook = TSC.WorkbookItem('test') From 2552c26819f2e13b1031aab94472678004c9d848 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Wed, 8 Apr 2020 18:11:02 -0500 Subject: [PATCH 136/567] Add flexibility for wkbk/ds id or item in endpoint (#570) * Add flexibility for wkbk/ds id or item in endpoint * Add API version number decorator * Write tests for workbook refresh * Correct refresh XML * Correct code style * Create tests for datasource refresh * Test job cancel on item Co-authored-by: Jordan Woods --- .../server/endpoint/datasources_endpoint.py | 4 +++- .../server/endpoint/jobs_endpoint.py | 3 ++- .../server/endpoint/workbooks_endpoint.py | 3 ++- test/assets/datasource_refresh.xml | 8 +++++++ test/assets/workbook_refresh.xml | 8 +++++++ test/test_datasource.py | 21 +++++++++++++++++ test/test_job.py | 11 ++++++++- test/test_workbook.py | 23 +++++++++++++++++++ 8 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 test/assets/datasource_refresh.xml create mode 100644 test/assets/workbook_refresh.xml diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index eef88d09e..44dea28df 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -144,8 +144,10 @@ def update_connection(self, datasource_item, connection_item): connection_item.id)) return connection + @api(version="2.8") def refresh(self, datasource_item): - url = "{0}/{1}/refresh".format(self.baseurl, datasource_item.id) + id_ = getattr(datasource_item, 'id', datasource_item) + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 92285c3db..e70c9c313 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -35,7 +35,8 @@ def get(self, job_id=None, req_options=None): @api(version='3.1') def cancel(self, job_id): - url = '{0}/{1}'.format(self.baseurl, job_id) + id_ = getattr(job_id, 'id', job_id) + url = '{0}/{1}'.format(self.baseurl, id_) return self.put_request(url) @api(version='2.6') diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a6a49fedf..1559bc41b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -56,7 +56,8 @@ def get_by_id(self, workbook_id): @api(version="2.8") def refresh(self, workbook_id): - url = "{0}/{1}/refresh".format(self.baseurl, workbook_id) + id_ = getattr(workbook_id, 'id', workbook_id) + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/test/assets/datasource_refresh.xml b/test/assets/datasource_refresh.xml new file mode 100644 index 000000000..61b4b7601 --- /dev/null +++ b/test/assets/datasource_refresh.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/workbook_refresh.xml b/test/assets/workbook_refresh.xml new file mode 100644 index 000000000..6f5da8283 --- /dev/null +++ b/test/assets/workbook_refresh.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index c90cf4601..2b7cc623c 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -16,6 +16,7 @@ POPULATE_PERMISSIONS_XML = 'datasource_populate_permissions.xml' PUBLISH_XML = 'datasource_publish.xml' PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' +REFRESH_XML = 'datasource_refresh.xml' UPDATE_XML = 'datasource_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -249,6 +250,26 @@ def test_publish_async(self): self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) self.assertEqual('1', new_job.finish_code) + def test_refresh_id(self): + self.server.version = '2.8' + self.baseurl = self.server.datasources.baseurl + response_xml = read_xml_asset(REFRESH_XML) + with requests_mock.mock() as m: + m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', + status_code=202, text=response_xml) + self.server.datasources.refresh('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + + def test_refresh_object(self): + self.server.version = '2.8' + self.baseurl = self.server.datasources.baseurl + datasource = TSC.DatasourceItem('') + datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + response_xml = read_xml_asset(REFRESH_XML) + with requests_mock.mock() as m: + m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', + status_code=202, text=response_xml) + self.server.datasources.refresh(datasource) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) diff --git a/test/test_job.py b/test/test_job.py index ee8316168..ee80450ca 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -45,7 +45,16 @@ def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) - def test_cancel(self): + def test_cancel_id(self): with requests_mock.mock() as m: m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + def test_cancel_item(self): + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) + job = TSC.JobItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'backgroundJob', + 0, created_at, started_at, None, 0) + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.jobs.cancel(job) diff --git a/test/test_workbook.py b/test/test_workbook.py index 06019cfac..1a62f4fc5 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -27,6 +27,7 @@ POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') +REFRESH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_refresh.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') @@ -114,6 +115,28 @@ def test_get_by_id(self): def test_get_by_id_missing_id(self): self.assertRaises(ValueError, self.server.workbooks.get_by_id, '') + def test_refresh_id(self): + self.server.version = '2.8' + self.baseurl = self.server.workbooks.baseurl + with open(REFRESH_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', + status_code=202, text=response_xml) + self.server.workbooks.refresh('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_refresh_object(self): + self.server.version = '2.8' + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem('') + workbook._id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' + with open(REFRESH_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', + status_code=202, text=response_xml) + self.server.workbooks.refresh(workbook) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42', status_code=204) From 5812dc9043348f7b80ad0c4dd363652a2351756d Mon Sep 17 00:00:00 2001 From: Dahai Guo Date: Thu, 9 Apr 2020 15:13:16 -0700 Subject: [PATCH 137/567] Adding DataAccelerationReport end point and item (#596) * fixing a bug due to bool(time(0,0)) is false * replace materialized views with data acceleration * made changes in tests * fixed a unit test * restore deleted lines by mistake * replace materialize_workbooks.py with accelerate_workbooks.py * undo a fix to test_sort.py * format changes * another format change * format change * remove accelerate_workbooks.py * adding additional fields to data_acceleration_config * add acceleration report endpoint * adding unit test * fixed over-indentation Co-authored-by: dguo --- tableauserverclient/models/__init__.py | 1 + .../models/data_acceleration_report_item.py | 75 +++++++++++++++++++ .../server/endpoint/__init__.py | 1 + .../data_acceleration_report_endpoint.py | 30 ++++++++ tableauserverclient/server/server.py | 3 +- test/assets/data_acceleration_report.xml | 20 +++++ test/test_data_acceleration_report.py | 42 +++++++++++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 tableauserverclient/models/data_acceleration_report_item.py create mode 100644 tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py create mode 100644 test/assets/data_acceleration_report.xml create mode 100644 test/test_data_acceleration_report.py diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 172877060..b5b50fe59 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,6 +1,7 @@ from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem from .column_item import ColumnItem +from .data_acceleration_report_item import DataAccelerationReportItem from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py new file mode 100644 index 000000000..2f056d0c4 --- /dev/null +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -0,0 +1,75 @@ +import xml.etree.ElementTree as ET + + +class DataAccelerationReportItem(object): + class ComparisonRecord(object): + def __init__(self, site, sheet_uri, unaccelerated_session_count, + avg_non_accelerated_plt, accelerated_session_count, + avg_accelerated_plt): + self._site = site + self._sheet_uri = sheet_uri + self._unaccelerated_session_count = unaccelerated_session_count + self._avg_non_accelerated_plt = avg_non_accelerated_plt + self._accelerated_session_count = accelerated_session_count + self._avg_accelerated_plt = avg_accelerated_plt + + @property + def site(self): + return self._site + + @property + def sheet_uri(self): + return self._sheet_uri + + @property + def site(self): + return self._site + + @property + def unaccelerated_session_count(self): + return self._unaccelerated_session_count + + @property + def accelerated_session_count(self): + return self._accelerated_session_count + + @property + def avg_accelerated_plt(self): + return self._avg_accelerated_plt + + @property + def avg_non_accelerated_plt(self): + return self._avg_non_accelerated_plt + + def __init__(self, comparison_records): + self._comparison_records = comparison_records + + @property + def comparison_records(self): + return self._comparison_records + + @staticmethod + def _parse_element(comparison_record_xml, ns): + site = comparison_record_xml.get('site', None) + sheet_uri = comparison_record_xml.get('sheetURI', None) + unaccelerated_session_count = comparison_record_xml.get('unacceleratedSessionCount', None) + avg_non_accelerated_plt = comparison_record_xml.get('averageNonAcceleratedPLT', None) + accelerated_session_count = comparison_record_xml.get('acceleratedSessionCount', None) + avg_accelerated_plt = comparison_record_xml.get('averageAcceleratedPLT', None) + return site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, \ + accelerated_session_count, avg_accelerated_plt + + @classmethod + def from_response(cls, resp, ns): + comparison_records = list() + parsed_response = ET.fromstring(resp) + all_comparison_records_xml = parsed_response.findall('.//t:comparisonRecord', namespaces=ns) + for comparison_record_xml in all_comparison_records_xml: + (site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, + accelerated_session_count, avg_accelerated_plt) = cls._parse_element(comparison_record_xml, ns) + + comparison_record = DataAccelerationReportItem.ComparisonRecord( + site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, + accelerated_session_count, avg_accelerated_plt) + comparison_records.append(comparison_record) + return cls(comparison_records) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 34c45a89a..fce86f98d 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,4 +1,5 @@ from .auth_endpoint import Auth +from .data_acceleration_report_endpoint import DataAccelerationReport from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py new file mode 100644 index 000000000..b84a38643 --- /dev/null +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -0,0 +1,30 @@ +from .endpoint import api, Endpoint +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from ...models.data_acceleration_report_item import DataAccelerationReportItem + +import logging + +logger = logging.getLogger('tableau.endpoint.data_acceleration_report') + + +class DataAccelerationReport(Endpoint): + def __init__(self, parent_srv): + super(DataAccelerationReport, self).__init__(parent_srv) + + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self): + return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.8") + def get(self, req_options=None): + logger.info("Querying data acceleration report") + url = self.baseurl + server_response = self.get_request(url, req_options) + data_acceleration_report = DataAccelerationReportItem.from_response( + server_response.content, self.parent_srv.namespace) + return data_acceleration_report diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 6c36482fd..42accf722 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, Webhooks + Databases, Tables, Flows, Webhooks, DataAccelerationReport from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -56,6 +56,7 @@ def __init__(self, server_address, use_server_version=False): self.databases = Databases(self) self.tables = Tables(self) self.webhooks = Webhooks(self) + self.data_acceleration_report = DataAccelerationReport(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/data_acceleration_report.xml b/test/assets/data_acceleration_report.xml new file mode 100644 index 000000000..51b86a691 --- /dev/null +++ b/test/assets/data_acceleration_report.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/test/test_data_acceleration_report.py b/test/test_data_acceleration_report.py new file mode 100644 index 000000000..7722bf230 --- /dev/null +++ b/test/test_data_acceleration_report.py @@ -0,0 +1,42 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'data_acceleration_report.xml' + + +class DataAccelerationReportTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.8" + + self.baseurl = self.server.data_acceleration_report.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + data_acceleration_report = self.server.data_acceleration_report.get() + + self.assertEqual(2, len(data_acceleration_report.comparison_records)) + + self.assertEqual("site-1", data_acceleration_report.comparison_records[0].site) + self.assertEqual("sheet-1", data_acceleration_report.comparison_records[0].sheet_uri) + self.assertEqual("0", data_acceleration_report.comparison_records[0].unaccelerated_session_count) + self.assertEqual("0.0", data_acceleration_report.comparison_records[0].avg_non_accelerated_plt) + self.assertEqual("1", data_acceleration_report.comparison_records[0].accelerated_session_count) + self.assertEqual("0.166", data_acceleration_report.comparison_records[0].avg_accelerated_plt) + + self.assertEqual("site-2", data_acceleration_report.comparison_records[1].site) + self.assertEqual("sheet-2", data_acceleration_report.comparison_records[1].sheet_uri) + self.assertEqual("2", data_acceleration_report.comparison_records[1].unaccelerated_session_count) + self.assertEqual("1.29", data_acceleration_report.comparison_records[1].avg_non_accelerated_plt) + self.assertEqual("3", data_acceleration_report.comparison_records[1].accelerated_session_count) + self.assertEqual("0.372", data_acceleration_report.comparison_records[1].avg_accelerated_plt) From 3142ee47acbfc8f4b9ac032cb00746c378a4efd7 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 14 Apr 2020 13:50:17 -0700 Subject: [PATCH 138/567] Add support for View Permissions (#526) Follow the permissions patterns :) --- .../models/permissions_item.py | 1 + tableauserverclient/models/view_item.py | 11 ++++ .../server/endpoint/views_endpoint.py | 14 +++++ test/assets/view_populate_permissions.xml | 19 ++++++ test/assets/view_update_permissions.xml | 21 +++++++ test/test_view.py | 58 ++++++++++++++++++- 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 test/assets/view_populate_permissions.xml create mode 100644 test/assets/view_update_permissions.xml diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 6487b6ca5..40049e3c4 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -38,6 +38,7 @@ class Resource: Flow = 'flow' Table = 'table' Database = 'database' + View = 'view' class PermissionsRule(object): diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 3dd9e065b..958cef7a2 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -21,6 +21,7 @@ def __init__(self): self._sheet_type = None self._updated_at = None self._workbook_id = None + self._permissions = None self.tags = set() def _set_preview_image(self, preview_image): @@ -106,6 +107,16 @@ def updated_at(self): def workbook_id(self): return self._workbook_id + @property + def permissions(self): + if self._permissions is None: + error = "View item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + def _set_permissions(self, permissions): + self._permissions = permissions + @classmethod def from_response(cls, resp, ns, workbook_id=''): return cls.from_xml_element(ET.fromstring(resp), ns, workbook_id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 62cd3af50..85ae70f93 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .resource_tagger import _ResourceTagger +from .permissions_endpoint import _PermissionsEndpoint from .. import RequestFactory, ViewItem, PaginationItem from ...models.tag_item import TagItem import logging @@ -13,6 +14,7 @@ class Views(Endpoint): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property @@ -109,6 +111,18 @@ def _get_view_csv(self, view_item, req_options): csv = server_response.iter_content(1024) return csv + @api(version='3.2') + def populate_permissions(self, item): + self._permissions.populate(item) + + @api(version='3.2') + def update_permissions(self, resource, rules): + return self._permissions.update(resource, rules) + + @api(version='3.2') + def delete_permission(self, item, capability_item): + return self._permissions.delete(item, capability_item) + # Update view. Currently only tags can be updated def update(self, view_item): if not view_item.id: diff --git a/test/assets/view_populate_permissions.xml b/test/assets/view_populate_permissions.xml new file mode 100644 index 000000000..e73616f46 --- /dev/null +++ b/test/assets/view_populate_permissions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/view_update_permissions.xml b/test/assets/view_update_permissions.xml new file mode 100644 index 000000000..2e78a4a90 --- /dev/null +++ b/test/assets/view_update_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_view.py b/test/test_view.py index fcf7d986c..350be83fd 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -4,6 +4,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient import UserItem, GroupItem, PermissionsRule TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -13,13 +14,15 @@ POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'Sample View Image.png') POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') POPULATE_CSV = os.path.join(TEST_ASSET_DIR, 'populate_csv.csv') +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'view_populate_permissions.xml') +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'view_update_permissions.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') class ViewTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - self.server.version = '2.7' + self.server.version = '3.2' # Fake sign in self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' @@ -170,6 +173,59 @@ def test_populate_image_missing_id(self): single_view._id = None self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_image, single_view) + def test_populate_permissions(self): + with open(POPULATE_PERMISSIONS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + "/e490bec4-2652-4fda-8c4e-f087db6fa328/permissions", text=response_xml) + single_view = TSC.ViewItem() + single_view._id = "e490bec4-2652-4fda-8c4e-f087db6fa328" + + self.server.views.populate_permissions(single_view) + permissions = single_view.permissions + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + + }) + + def test_add_permissions(self): + with open(UPDATE_PERMISSIONS, 'rb') as f: + response_xml = f.read().decode('utf-8') + + single_view = TSC.ViewItem() + single_view._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + + bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + PermissionsRule(bob, {'Write': 'Allow'}), + PermissionsRule(group_of_people, {'Read': 'Deny'}) + ] + + with requests_mock.mock() as m: + m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = self.server.views.update_permissions(single_view, new_permissions) + + self.assertEqual(permissions[0].grantee.tag_name, 'group') + self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') + self.assertDictEqual(permissions[0].capabilities, { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny + }) + + self.assertEqual(permissions[1].grantee.tag_name, 'user') + self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') + self.assertDictEqual(permissions[1].capabilities, { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow + }) + def test_update_tags(self): with open(ADD_TAGS_XML, 'rb') as f: add_tags_xml = f.read().decode('utf-8') From 42592bd7adc732200de735dd47d6b60ed6164a73 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 24 Apr 2020 13:16:25 -0700 Subject: [PATCH 139/567] Fixes print statment error in update_connection sample (#602) --- samples/update_connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/update_connection.py b/samples/update_connection.py index 36001b379..69e4e6377 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -56,7 +56,7 @@ def main(): connection.username = args.datasource_username connection.password = args.datasource_password connection.embed_password = True - print(update_function(resource, connection).content) + print(update_function(resource, connection).__dict__) if __name__ == '__main__': From b4a1aeb622862e3dc3c4361c74088a4ab3d34061 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 1 May 2020 09:13:50 -0700 Subject: [PATCH 140/567] Fixes default permissions urls and adds return value to update endpoints (#603) * Fixes default permissions urls and adds return value to update endpoints * Fix code style error * Adds samples for using permissions and default permissions endpoints --- samples/add_default_permission.py | 84 +++++++++++++++++++ samples/query_permissions.py | 75 +++++++++++++++++ .../server/endpoint/databases_endpoint.py | 4 +- .../endpoint/default_permissions_endpoint.py | 4 +- .../server/endpoint/projects_endpoint.py | 8 +- ..._update_datasource_default_permissions.xml | 17 ++++ test/test_project.py | 32 ++++++- 7 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 samples/add_default_permission.py create mode 100644 samples/query_permissions.py create mode 100644 test/assets/project_update_datasource_default_permissions.xml diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py new file mode 100644 index 000000000..b6dbdd479 --- /dev/null +++ b/samples/add_default_permission.py @@ -0,0 +1,84 @@ +#### +# This script demonstrates how to add default permissions using TSC +# To run the script, you must have installed Python 3.5 and later. +# +# In order to demonstrate adding a new default permission, this sample will create +# a new project and add a new capability to the new project, for the default "All users" group. +# +# Example usage: 'python add_default_permission.py -s +# https://10ax.online.tableau.com --site devSite123 -u tabby@tableau.com' +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Add workbook default permission for a given project') + parser.add_argument('--server', '-s', required=True, help='Server address') + parser.add_argument('--username', '-u', required=True, help='Username to sign into server') + parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') + parser.add_argument('-p', default=None, help='Password to sign into server') + + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + + args = parser.parse_args() + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # 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) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + + # Create a sample project + project = TSC.ProjectItem("sample_project") + project = server.projects.create(project) + + # Query for existing workbook default-permissions + server.projects.populate_workbook_default_permissions(project) + default_permissions = project.default_workbook_permissions[0] # new projects have 1 grantee group + + # Add "ExportXml (Allow)" workbook capability to "All Users" default group if it does not already exist + if TSC.Permission.Capability.ExportXml not in default_permissions.capabilities: + new_capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow} + + # Each PermissionRule in the list contains a grantee and a dict of capabilities + new_rules = [TSC.PermissionsRule( + grantee=default_permissions.grantee, + capabilities=new_capabilities + )] + + new_default_permissions = server.projects.update_workbook_default_permissions(project, new_rules) + + # Print result from adding a new default permission + for permission in new_default_permissions: + grantee = permission.grantee + capabilities = permission.capabilities + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + + for capability in capabilities: + print("\t{0} - {1}".format(capability, capabilities[capability])) + + # Uncomment lines below to DELETE the new capability and the new project + # rules_to_delete = TSC.PermissionsRule( + # grantee=default_permissions.grantee, + # capabilities=new_capabilities + # ) + # server.projects.delete_workbook_default_permissions(project, rules_to_delete) + # server.projects.delete(project.id) + + +if __name__ == '__main__': + main() diff --git a/samples/query_permissions.py b/samples/query_permissions.py new file mode 100644 index 000000000..48120f398 --- /dev/null +++ b/samples/query_permissions.py @@ -0,0 +1,75 @@ +#### +# This script demonstrates how to query for permissions using TSC +# To run the script, you must have installed Python 3.5 and later. +# +# Example usage: 'python query_permissions.py -s https://10ax.online.tableau.com --site +# devSite123 -u tabby@tableau.com workbook b4065286-80f0-11ea-af1b-cb7191f48e45' +#### + +import argparse +import getpass +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Query permissions of a given resource') + parser.add_argument('--server', '-s', required=True, help='Server address') + parser.add_argument('--username', '-u', required=True, help='Username to sign into server') + parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') + parser.add_argument('-p', default=None, help='Password to sign into server') + + 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', 'flow', 'table', 'database']) + parser.add_argument('resource_id') + + args = parser.parse_args() + + if args.p is None: + password = getpass.getpass("Password: ") + else: + password = args.p + + # 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) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + + # Mapping to grab the handler for the user-inputted resource type + endpoint = { + 'workbook': server.workbooks, + 'datasource': server.datasources, + 'flow': server.flows, + 'table': server.tables, + 'database': server.databases + }.get(args.resource_type) + + # Get the resource by its ID + resource = endpoint.get_by_id(args.resource_id) + + # Populate permissions for the resource + endpoint.populate_permissions(resource) + permissions = resource.permissions + + # Print result + print("\n{0} permission rule(s) found for {1} {2}." + .format(len(permissions), args.resource_type, args.resource_id)) + + for permission in permissions: + grantee = permission.grantee + capabilities = permission.capabilities + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + + for capability in capabilities: + print("\t{0} - {1}".format(capability, capabilities[capability])) + + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0726abe2..d0fd24c78 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -93,7 +93,7 @@ def update_permission(self, item, rules): @api(version='3.5') def delete_permission(self, item, rules): - return self._permissions.delete(item, rules) + self._permissions.delete(item, rules) @api(version='3.5') def populate_table_default_permissions(self, item): @@ -101,7 +101,7 @@ def populate_table_default_permissions(self, item): @api(version='3.5') def update_table_default_permissions(self, item): - self._default_permissions.update_default_permissions(item, Permission.Resource.Table) + return self._default_permissions.update_default_permissions(item, Permission.Resource.Table) @api(version='3.5') def delete_table_default_permissions(self, item): diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 9934ee176..0dff025a1 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -27,7 +27,7 @@ def __init__(self, parent_srv, owner_baseurl): self.owner_baseurl = owner_baseurl def update_default_permissions(self, resource, permissions, content_type): - url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), resource.id, content_type) + url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), resource.id, content_type + 's') update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, @@ -44,7 +44,7 @@ def delete_default_permission(self, resource, rule, content_type): .format( baseurl=self.owner_baseurl(), content_id=resource.id, - content_type=content_type, + content_type=content_type + 's', grantee_type=rule.grantee.tag_name + 's', grantee_id=rule.grantee.id, cap=capability, diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 3b5216899..f0eb92626 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -71,7 +71,7 @@ def update_permission(self, item, rules): @api(version='2.0') def delete_permission(self, item, rules): - return self._permissions.delete(item, rules) + self._permissions.delete(item, rules) @api(version='2.1') def populate_workbook_default_permissions(self, item): @@ -87,15 +87,15 @@ def populate_flow_default_permissions(self, item): @api(version='2.1') def update_workbook_default_permissions(self, item, rules): - self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) + return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @api(version='2.1') def update_datasource_default_permissions(self, item, rules): - self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) + return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) @api(version='3.4') def update_flow_default_permissions(self, item, rules): - self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) + return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) @api(version='2.1') def delete_workbook_default_permissions(self, item, rule): diff --git a/test/assets/project_update_datasource_default_permissions.xml b/test/assets/project_update_datasource_default_permissions.xml new file mode 100644 index 000000000..3a70031ce --- /dev/null +++ b/test/assets/project_update_datasource_default_permissions.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_project.py b/test/test_project.py index d4a0de283..b57d52df5 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -12,6 +12,7 @@ CREATE_XML = asset('project_create.xml') POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml' POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml' +UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = 'project_update_datasource_default_permissions.xml' class ProjectTests(unittest.TestCase): @@ -79,6 +80,35 @@ def test_update(self): self.assertEqual('LockedToProject', single_project.content_permissions) self.assertEqual('9a8f2265-70f3-4494-96c5-e5949d7a1120', single_project.parent_id) + def test_update_datasource_default_permission(self): + response_xml = read_xml_asset(UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources', + text=response_xml) + project = TSC.ProjectItem('test-project') + project._id = 'b4065286-80f0-11ea-af1b-cb7191f48e45' + + group = TSC.GroupItem('test-group') + group._id = 'b4488bce-80f0-11ea-af1c-976d0c1dab39' + + capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} + + rules = [TSC.PermissionsRule( + grantee=group, + capabilities=capabilities + )] + + new_rules = self.server.projects.update_datasource_default_permissions(project, rules) + + self.assertEquals('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + + updated_capabilities = new_rules[0].capabilities + self.assertEquals(4, len(updated_capabilities)) + self.assertEquals('Deny', updated_capabilities['ExportXml']) + self.assertEquals('Allow', updated_capabilities['Read']) + self.assertEquals('Allow', updated_capabilities['Write']) + self.assertEquals('Allow', updated_capabilities['Connect']) + def test_update_missing_id(self): single_project = TSC.ProjectItem('test') self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) @@ -230,7 +260,7 @@ def test_delete_workbook_default_permission(self): capabilities=capabilities ) - endpoint = '{}/default-permissions/workbook/groups/{}'.format(single_project._id, single_group._id) + endpoint = '{}/default-permissions/workbooks/groups/{}'.format(single_project._id, single_group._id) m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) m.delete('{}/{}/ExportImage/Allow'.format(self.baseurl, endpoint), status_code=204) m.delete('{}/{}/ExportData/Allow'.format(self.baseurl, endpoint), status_code=204) From c6bf1e6c6dc4437e871d5ff80b8b2b646a6db5fe Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 1 May 2020 09:17:17 -0700 Subject: [PATCH 141/567] Adding OpenID as an auth setting enum (#610) --- tableauserverclient/models/user_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 10ca7527d..3df2004bf 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -31,6 +31,7 @@ class Roles: SupportUser = 'SupportUser' class Auth: + OpenID = 'OpenID' SAML = 'SAML' ServerDefault = 'ServerDefault' From 977b3d95134379a3ff4b338ba7338ef26953706e Mon Sep 17 00:00:00 2001 From: Reba Magier Date: Fri, 1 May 2020 12:17:47 -0400 Subject: [PATCH 142/567] Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user --- tableauserverclient/server/endpoint/users_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 64b296543..0949a5e5b 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -65,7 +65,7 @@ def add(self, user_item): add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info('Added new user (ID: {0})'.format(user_item.id)) + logger.info('Added new user (ID: {0})'.format(new_user.id)) return new_user # Get workbooks for user From 03f76292d97d1a3302761247a4bc3e91d427c3ec Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 1 May 2020 09:47:51 -0700 Subject: [PATCH 143/567] V0.11 release (#611) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Updates changelog and contributors for v0.11 release Co-authored-by: Jac Co-authored-by: Reba Magier --- CHANGELOG.md | 11 + CONTRIBUTORS.md | 3 +- docs/Gemfile | 3 - docs/{docs/readme.md => README.md} | 0 docs/_config.yml | 17 - docs/_includes/analytics.html | 7 - docs/_includes/docs_menu.html | 73 -- docs/_includes/footer.html | 8 - docs/_includes/head.html | 18 - docs/_includes/header.html | 29 - docs/_includes/icon-github.svg | 1 - docs/_includes/search_form.html | 7 - docs/_layouts/default.html | 34 - docs/_layouts/docs.html | 31 - docs/_layouts/home.html | 19 - docs/_layouts/search.html | 43 -- docs/assets/logo.png | Bin 2800 -> 0 bytes docs/css/api_ref.css | 709 ------------------ docs/css/extra.css | 14 - docs/css/github-highlight.css | 224 ------ docs/css/main.css | 276 ------- docs/index.md | 13 - docs/js/lunr.min.js | 6 - docs/js/redirect-to-search.js | 13 - docs/js/search.js | 71 -- .../server/endpoint/users_endpoint.py | 2 +- 26 files changed, 14 insertions(+), 1618 deletions(-) delete mode 100644 docs/Gemfile rename docs/{docs/readme.md => README.md} (100%) delete mode 100644 docs/_config.yml delete mode 100644 docs/_includes/analytics.html delete mode 100644 docs/_includes/docs_menu.html delete mode 100644 docs/_includes/footer.html delete mode 100644 docs/_includes/head.html delete mode 100644 docs/_includes/header.html delete mode 100644 docs/_includes/icon-github.svg delete mode 100644 docs/_includes/search_form.html delete mode 100644 docs/_layouts/default.html delete mode 100644 docs/_layouts/docs.html delete mode 100644 docs/_layouts/home.html delete mode 100644 docs/_layouts/search.html delete mode 100644 docs/assets/logo.png delete mode 100644 docs/css/api_ref.css delete mode 100644 docs/css/extra.css delete mode 100644 docs/css/github-highlight.css delete mode 100644 docs/css/main.css delete mode 100644 docs/index.md delete mode 100644 docs/js/lunr.min.js delete mode 100644 docs/js/redirect-to-search.js delete mode 100644 docs/js/search.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6189ab78b..dfbf46748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.11 (1 May 2020) + +* Added more fields to Data Acceleration config (#588) +* Added OpenID as an auth setting enum (#610) +* Added support for Data Acceleration Reports (#596) +* Added support for view permissions (#526) +* Materialized views changed to Data Acceleration (#576) +* Improved consistency across workbook/datasource endpoints (#570) +* Fixed print error in update_connection.py (#602) +* Fixed log error in add user endpoint (#608) + ## 0.10 (21 Feb 2020) * Added a way to handle non-xml errors (#515) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index a23213598..cc92fc435 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -34,6 +34,8 @@ The following people have contributed to this project to make it possible, and w * [Kacper Wolkiewicz](https://github.com/wolkiewiczk) * [Dahai Guo](https://github.com/guodah) * [Geraldine Zanolli](https://github.com/illonage) +* [Jordan Woods](https://github.com/jorwoods) +* [Reba Magier](https://github.com/rmagier1) ## Core Team @@ -42,7 +44,6 @@ The following people have contributed to this project to make it possible, and w * [Tyler Doyle](https://github.com/t8y8) * [Russell Hay](https://github.com/RussTheAerialist) * [Ben Lower](https://github.com/benlower) -* [Jackson Huang](https://github.com/jz-huang) * [Ang Gao](https://github.com/gaoang2148) * [Priya Reguraman](https://github.com/preguraman) * [Jac Fitzgerald](https://github.com/jacalata) diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index 775d954bf..000000000 --- a/docs/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' -gem 'github-pages', group: :jekyll_plugins - diff --git a/docs/docs/readme.md b/docs/README.md similarity index 100% rename from docs/docs/readme.md rename to docs/README.md diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index 5ea15f228..000000000 --- a/docs/_config.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Site settings -title: Tableau Server Client Library (Python) -email: github@tableau.com -description: Simplify interactions with the Tableau Server REST API. -baseurl: "/https/github.com/server-client-python" -permalinks: pretty -defaults: - - - scope: - path: "" # Apply to all files - values: - layout: "default" - -# Build settings -markdown: kramdown -highlighter: rouge - diff --git a/docs/_includes/analytics.html b/docs/_includes/analytics.html deleted file mode 100644 index 0cdbad25d..000000000 --- a/docs/_includes/analytics.html +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/docs/_includes/docs_menu.html b/docs/_includes/docs_menu.html deleted file mode 100644 index 104a1f5b3..000000000 --- a/docs/_includes/docs_menu.html +++ /dev/null @@ -1,73 +0,0 @@ -
- {% include search_form.html %} - -
diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html deleted file mode 100644 index 486c81d22..000000000 --- a/docs/_includes/footer.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
- -

This site is open source. Suggestions and pull requests are welcome on our GitHub page.

-

© 2016 Tableau.

-
-
diff --git a/docs/_includes/head.html b/docs/_includes/head.html deleted file mode 100644 index 083e3f268..000000000 --- a/docs/_includes/head.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - {% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %} - - - - - - - - - - - - -{% if jekyll.environment == "production" %}{% include analytics.html %}{% endif %} diff --git a/docs/_includes/header.html b/docs/_includes/header.html deleted file mode 100644 index 106578dfc..000000000 --- a/docs/_includes/header.html +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/docs/_includes/icon-github.svg b/docs/_includes/icon-github.svg deleted file mode 100644 index 4422c4f5d..000000000 --- a/docs/_includes/icon-github.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/_includes/search_form.html b/docs/_includes/search_form.html deleted file mode 100644 index 41bb34259..000000000 --- a/docs/_includes/search_form.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
- diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index 38ee020bb..000000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - {% include head.html %} - - - -
- {% include header.html %} -
    - {% for post in site.posts %} -
    -

    {{ post.title }}

    -
    -

    Posted on {{ post.date | date: "%-d %B %Y" }}

    -
    -

    - {{ post.abstract }} -

    - {% if post.photoname %} - {% endif %} -
    -
    - {{ post.content }} -
    -
    - {% endfor %} -
- {% include footer.html %} -
- - - diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html deleted file mode 100644 index 5355f63df..000000000 --- a/docs/_layouts/docs.html +++ /dev/null @@ -1,31 +0,0 @@ ---- -layout: docs ---- - - - - - - {% include head.html %} - - - -
- {% include header.html %} - {% include docs_menu.html %} - -
-

{{ page.title }}

- -
- {{ content }} - {% include footer.html %} -
-
- - - diff --git a/docs/_layouts/home.html b/docs/_layouts/home.html deleted file mode 100644 index c2cf32fcb..000000000 --- a/docs/_layouts/home.html +++ /dev/null @@ -1,19 +0,0 @@ ---- -layout: home ---- - - - - - {% include head.html %} - - - -
- {% include header.html %} - {{ content }} - {% include footer.html %} -
- - - diff --git a/docs/_layouts/search.html b/docs/_layouts/search.html deleted file mode 100644 index 96dbd94a1..000000000 --- a/docs/_layouts/search.html +++ /dev/null @@ -1,43 +0,0 @@ ---- -layout: search ---- - - - - - - {% include head.html %} - - - - - - - -
- {% include header.html %} - {% include docs_menu.html %} - -
-

-
-
-

Loading search results...

-
- - {% include footer.html %} -
-
- - diff --git a/docs/assets/logo.png b/docs/assets/logo.png deleted file mode 100644 index 60761152152291896e7b27f94d981fc82e71a2dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2800 zcmb7GdpuNWA3rk7jj(zc zA_9#@6Cwf+j{N!tppenAD|!*Tt? zar#(2+n89aH%lSV-lRb3+Ff~Q<4W3MbT z@VqTFFgsTxdBECEUTw_=QFxAOl%7~&W%r{p6`lSZYEWfw_N(j+_voCtm@(sQg^ZaR zsIPw<-y$EgstQv?0+7GAo#~0Ybf<5&_#AxMFXwb?)9ujtx3<&f(hsV490=3nB^pAJ zMTU9sKs7)h8oMvf0Qh2Q4X6li#W2BNGXt$=HfdHSpi}0iurAQrwHK3}3Gfe>Z7BX< zq3$f|V$_05hx;%OITN-*535$zVg^snD5$=sNBEW(^)1Yyj%k;vy{wVRpW9#M8}ico z`xoM42SX)!goCAfAkAyd301SblpHR7C55iE*E9iL;}XNM-OoAN8fZ={l9N%*^3B|5 z+V!G$`?J+2lS2Zi$d%7H)4u(#VPVgB&d*}qPUgG#ThLM3Ie<@F!WLyAXzZ~#FYjD+ zIcvcy&*fuR^=SCM2%}Q-v zc6@V!Cjpy)p^eN{4k)7%h1+@LE#?q-XV*rWJ!rK@qaIQK(v4YOFAB7x9QfDe6NM9T z-$s!EQY1{y|L>u^zOJag>xEZBY%Z{hcR3Wh0HoqajIrx=-0Sw7LfNocy;ckD zlN=YnmJy+F`L(I;)F4-+U7OKAJsbV3r2%VZ}eDU0r44YGzvYI}6J8SJ|guC1AP%`f-qACE3fV`|%cn zrm1{UtI64mKbG$DOOHzKkquq?rE8foh%;9Zm5nB+dL)iK+JaZ!U~6g!!hIxBnI&b# z$l_tno&l91PI7OBh3n}-#xAoh>oCc6XWvxt`wJTuf^d(s<}9GdK6Q<@;aQV|ORNy{ zSCSp6KuQ-%D(pXbx-1S)|y2kxwzf%4+Yp1u28EW3orO>jQfZQH*0SGO#V6wj~LH z<)E0yt_=N*&R#lMQ)g8PJQE`8ISv+%*X0_3T9W3rIcWicZqKG%Yk(AR--d?gIh72) z2r+UimAg<>@mA|l^qZc=n4@o@>6*ej!eE54DU+`cUpewrq~Ax-pgA#OG0qwL6UKbl z=eL@fTZ+?e-#8TA!rJd7GLVpNwr!iz;6k09xMrmF4 zt`Smbk^5c}plr=9^O)~g_!J7(`n47)Ylp(22O5Cjh!n;$4dB}~!?dtq_0Gs8N3eQ1 zP17Ubq4G{@a|0!xqt}$?3}}UEp`@G7$sOPMYInvg^IFEIqhj{zM)b;clX8_^($FpV zt<%~0;HRkUMv~TXfd4S@#1#${xvfNjuoX5p@EfZjDDz}~u|v7Nrpl4=zes(cc3LCn9^5yHcG4y0kRne# ztY1!+o3y&qA zttx95Z>yADY4Q91Ulv7;c6mO(mRHs9)^Np*XLpaxze8FIHcYEz0#evG5M-$;1_?3( za|j;>1-mGX;#mPCHLIkrNa>*{o6z_CiqqjK0lkSx&FzkS7Yk+B-62H&(gnE395AbQp4>LZYT`AK6 z;mN3uerOU4Zk;)^D5Po7bTw{9&P-|lK+$M(zbm&!Xc)N6RXFL6Tkw02sw!d+QCar@ z5bLMn&_+=8JJX;%;!;wr(0~AiZc26oNF#=17!(BX|MH;|H80s{HcH;Y$85@-7/*! normalize.css v1.1.3 | MIT License | git.io/normalize */ -/* 2 */ -/* ========================================================================== Tables ========================================================================== */ -/** Remove most spacing between table cells. */ -table { border-collapse: collapse; border-spacing: 0; } - -/* Visual Studio-like style based on original C# coloring by Jason Diamond */ -.hljs { display: inline-block; padding: 0.5em; background: white; color: black; } - -.hljs-comment, .hljs-annotation, .hljs-template_comment, .diff .hljs-header, .hljs-chunk, .apache .hljs-cbracket { color: #008000; } - -.hljs-keyword, .hljs-id, .hljs-built_in, .css .smalltalk .hljs-class, .hljs-winutils, .bash .hljs-variable, .tex .hljs-command, .hljs-request, .hljs-status, .nginx .hljs-title { color: #00f; } - -.xml .hljs-tag { color: #00f; } -.xml .hljs-tag .hljs-value { color: #00f; } - -.hljs-string, .hljs-title, .hljs-parent, .hljs-tag .hljs-value, .hljs-rules .hljs-value { color: #a31515; } - -.ruby .hljs-symbol { color: #a31515; } -.ruby .hljs-symbol .hljs-string { color: #a31515; } - -.hljs-template_tag, .django .hljs-variable, .hljs-addition, .hljs-flow, .hljs-stream, .apache .hljs-tag, .hljs-date, .tex .hljs-formula, .coffeescript .hljs-attribute { color: #a31515; } - -.ruby .hljs-string, .hljs-decorator, .hljs-filter .hljs-argument, .hljs-localvars, .hljs-array, .hljs-attr_selector, .hljs-pseudo, .hljs-pi, .hljs-doctype, .hljs-deletion, .hljs-envvar, .hljs-shebang, .hljs-preprocessor, .hljs-pragma, .userType, .apache .hljs-sqbracket, .nginx .hljs-built_in, .tex .hljs-special, .hljs-prompt { color: #2b91af; } - -.hljs-phpdoc, .hljs-javadoc, .hljs-xmlDocTag { color: #808080; } - -.vhdl .hljs-typename { font-weight: bold; } -.vhdl .hljs-string { color: #666666; } -.vhdl .hljs-literal { color: #a31515; } -.vhdl .hljs-attribute { color: #00b0e8; } - -.xml .hljs-attribute { color: #f00; } - -.col > :first-child, .col-1 > :first-child, .col-2 > :first-child, .col-3 > :first-child, .col-4 > :first-child, .col-5 > :first-child, .col-6 > :first-child, .col-7 > :first-child, .col-8 > :first-child, .col-9 > :first-child, .col-10 > :first-child, .col-11 > :first-child, .tsd-panel > :first-child, ul.tsd-descriptions > li > :first-child, .col > :first-child > :first-child, .col-1 > :first-child > :first-child, .col-2 > :first-child > :first-child, .col-3 > :first-child > :first-child, .col-4 > :first-child > :first-child, .col-5 > :first-child > :first-child, .col-6 > :first-child > :first-child, .col-7 > :first-child > :first-child, .col-8 > :first-child > :first-child, .col-9 > :first-child > :first-child, .col-10 > :first-child > :first-child, .col-11 > :first-child > :first-child, .tsd-panel > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child, .col > :first-child > :first-child > :first-child, .col-1 > :first-child > :first-child > :first-child, .col-2 > :first-child > :first-child > :first-child, .col-3 > :first-child > :first-child > :first-child, .col-4 > :first-child > :first-child > :first-child, .col-5 > :first-child > :first-child > :first-child, .col-6 > :first-child > :first-child > :first-child, .col-7 > :first-child > :first-child > :first-child, .col-8 > :first-child > :first-child > :first-child, .col-9 > :first-child > :first-child > :first-child, .col-10 > :first-child > :first-child > :first-child, .col-11 > :first-child > :first-child > :first-child, .tsd-panel > :first-child > :first-child > :first-child, ul.tsd-descriptions > li > :first-child > :first-child > :first-child { margin-top: 0; } -.col > :last-child, .col-1 > :last-child, .col-2 > :last-child, .col-3 > :last-child, .col-4 > :last-child, .col-5 > :last-child, .col-6 > :last-child, .col-7 > :last-child, .col-8 > :last-child, .col-9 > :last-child, .col-10 > :last-child, .col-11 > :last-child, .tsd-panel > :last-child, ul.tsd-descriptions > li > :last-child, .col > :last-child > :last-child, .col-1 > :last-child > :last-child, .col-2 > :last-child > :last-child, .col-3 > :last-child > :last-child, .col-4 > :last-child > :last-child, .col-5 > :last-child > :last-child, .col-6 > :last-child > :last-child, .col-7 > :last-child > :last-child, .col-8 > :last-child > :last-child, .col-9 > :last-child > :last-child, .col-10 > :last-child > :last-child, .col-11 > :last-child > :last-child, .tsd-panel > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child, .col > :last-child > :last-child > :last-child, .col-1 > :last-child > :last-child > :last-child, .col-2 > :last-child > :last-child > :last-child, .col-3 > :last-child > :last-child > :last-child, .col-4 > :last-child > :last-child > :last-child, .col-5 > :last-child > :last-child > :last-child, .col-6 > :last-child > :last-child > :last-child, .col-7 > :last-child > :last-child > :last-child, .col-8 > :last-child > :last-child > :last-child, .col-9 > :last-child > :last-child > :last-child, .col-10 > :last-child > :last-child > :last-child, .col-11 > :last-child > :last-child > :last-child, .tsd-panel > :last-child > :last-child > :last-child, ul.tsd-descriptions > li > :last-child > :last-child > :last-child { margin-bottom: 0; } - -@media (max-width: 640px) { .container { padding: 0 20px; } } - -.container-main { padding-bottom: 200px; } - -.row { position: relative; margin: 0 -10px; } -.row:after { visibility: hidden; display: block; content: ""; clear: both; height: 0; } - -.col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11 { box-sizing: border-box; float: left; padding: 0 10px; } - -.col-1 { width: 8.33333%; } - -.offset-1 { margin-left: 8.33333%; } - -.col-2 { width: 16.66667%; } - -.offset-2 { margin-left: 16.66667%; } - -.col-3 { width: 25%; } - -.offset-3 { margin-left: 25%; } - -.col-4 { width: 33.33333%; } - -.offset-4 { margin-left: 33.33333%; } - -.col-5 { width: 41.66667%; } - -.offset-5 { margin-left: 41.66667%; } - -.col-6 { width: 50%; } - -.offset-6 { margin-left: 50%; } - -.col-7 { width: 58.33333%; } - -.offset-7 { margin-left: 58.33333%; } - -.col-8 { width: 66.66667%; } - -.offset-8 { margin-left: 66.66667%; } - -.col-9 { width: 75%; } - -.offset-9 { margin-left: 75%; } - -.col-10 { width: 83.33333%; } - -.offset-10 { margin-left: 83.33333%; } - -.col-11 { width: 91.66667%; } - -.offset-11 { margin-left: 91.66667%; } - -.tsd-kind-icon { display: block; position: relative; padding-left: 20px; text-indent: -20px; } -.tsd-kind-icon:before { content: ''; display: inline-block; vertical-align: middle; width: 17px; height: 17px; margin: 0 3px 2px 0; background-image: url(data:image/png;base64,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); } -@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-kind-icon:before { background-image: url(data:image/png;base64,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); background-size: 238px 204px; } } - -.tsd-signature.tsd-kind-icon:before { background-position: 0 -153px; } - -.tsd-kind-object-literal > .tsd-kind-icon:before { background-position: 0px -17px; } -.tsd-kind-object-literal.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -17px; } -.tsd-kind-object-literal.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -17px; } - -.tsd-kind-class > .tsd-kind-icon:before { background-position: 0px -34px; } -.tsd-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -34px; } -.tsd-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -34px; } - -.tsd-kind-class.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -51px; } -.tsd-kind-class.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -51px; } -.tsd-kind-class.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -51px; } - -.tsd-kind-interface > .tsd-kind-icon:before { background-position: 0px -68px; } -.tsd-kind-interface.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -68px; } -.tsd-kind-interface.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -68px; } - -.tsd-kind-interface.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: 0px -85px; } -.tsd-kind-interface.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -85px; } -.tsd-kind-interface.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -85px; } - -.tsd-kind-module > .tsd-kind-icon:before { background-position: 0px -102px; } -.tsd-kind-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } -.tsd-kind-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } - -.tsd-kind-external-module > .tsd-kind-icon:before { background-position: 0px -102px; } -.tsd-kind-external-module.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -102px; } -.tsd-kind-external-module.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -102px; } - -.tsd-kind-enum > .tsd-kind-icon:before { background-position: 0px -119px; } -.tsd-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -119px; } -.tsd-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -119px; } - -.tsd-kind-enum-member > .tsd-kind-icon:before { background-position: 0px -136px; } -.tsd-kind-enum-member.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -136px; } -.tsd-kind-enum-member.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -136px; } - -.tsd-kind-signature > .tsd-kind-icon:before { background-position: 0px -153px; } -.tsd-kind-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -153px; } -.tsd-kind-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -153px; } - -.tsd-kind-type-alias > .tsd-kind-icon:before { background-position: 0px -170px; } -.tsd-kind-type-alias.tsd-is-protected > .tsd-kind-icon:before { background-position: -17px -170px; } -.tsd-kind-type-alias.tsd-is-private > .tsd-kind-icon:before { background-position: -34px -170px; } - -.tsd-kind-variable > .tsd-kind-icon:before { background-position: -136px -0px; } -.tsd-kind-variable.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } -.tsd-kind-variable.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } -.tsd-kind-variable.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } -.tsd-kind-variable.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-variable.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } -.tsd-kind-variable.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } - -.tsd-kind-property > .tsd-kind-icon:before { background-position: -136px -0px; } -.tsd-kind-property.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -0px; } -.tsd-kind-property.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -0px; } -.tsd-kind-property.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -0px; } -.tsd-kind-property.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -0px; } -.tsd-kind-property.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -0px; } -.tsd-kind-property.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -0px; } -.tsd-kind-property.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -0px; } - -.tsd-kind-get-signature > .tsd-kind-icon:before { background-position: -136px -17px; } -.tsd-kind-get-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -17px; } -.tsd-kind-get-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -17px; } -.tsd-kind-get-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -17px; } - -.tsd-kind-set-signature > .tsd-kind-icon:before { background-position: -136px -34px; } -.tsd-kind-set-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -34px; } -.tsd-kind-set-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -34px; } -.tsd-kind-set-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -34px; } - -.tsd-kind-accessor > .tsd-kind-icon:before { background-position: -136px -51px; } -.tsd-kind-accessor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -51px; } -.tsd-kind-accessor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -51px; } -.tsd-kind-accessor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -51px; } -.tsd-kind-accessor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -51px; } -.tsd-kind-accessor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -51px; } -.tsd-kind-accessor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -51px; } - -.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -68px; } -.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -68px; } -.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -68px; } -.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -68px; } - -.tsd-kind-function.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } -.tsd-kind-function.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } - -.tsd-kind-method.tsd-has-type-parameter > .tsd-kind-icon:before { background-position: -136px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -85px; } -.tsd-kind-method.tsd-has-type-parameter.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -85px; } - -.tsd-kind-constructor > .tsd-kind-icon:before { background-position: -136px -102px; } -.tsd-kind-constructor.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } -.tsd-kind-constructor.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } -.tsd-kind-constructor.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } -.tsd-kind-constructor.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } -.tsd-kind-constructor.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } - -.tsd-kind-constructor-signature > .tsd-kind-icon:before { background-position: -136px -102px; } -.tsd-kind-constructor-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -102px; } -.tsd-kind-constructor-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -102px; } -.tsd-kind-constructor-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -102px; } - -.tsd-kind-index-signature > .tsd-kind-icon:before { background-position: -136px -119px; } -.tsd-kind-index-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -119px; } -.tsd-kind-index-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -119px; } -.tsd-kind-index-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -119px; } - -.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -136px; } -.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -136px; } -.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -136px; } -.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -136px; } -.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -136px; } -.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -136px; } -.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -136px; } -.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -136px; } - -.tsd-is-static > .tsd-kind-icon:before { background-position: -136px -153px; } -.tsd-is-static.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -153px; } -.tsd-is-static.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -153px; } -.tsd-is-static.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -153px; } -.tsd-is-static.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -153px; } -.tsd-is-static.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -153px; } -.tsd-is-static.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -153px; } -.tsd-is-static.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -153px; } - -.tsd-is-static.tsd-kind-function > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-function.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-function.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-function.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-method > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-method.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-method.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-method.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-call-signature > .tsd-kind-icon:before { background-position: -136px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -170px; } -.tsd-is-static.tsd-kind-call-signature.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -170px; } - -.tsd-is-static.tsd-kind-event > .tsd-kind-icon:before { background-position: -136px -187px; } -.tsd-is-static.tsd-kind-event.tsd-is-protected > .tsd-kind-icon:before { background-position: -153px -187px; } -.tsd-is-static.tsd-kind-event.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class > .tsd-kind-icon:before { background-position: -51px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-inherited > .tsd-kind-icon:before { background-position: -68px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected > .tsd-kind-icon:before { background-position: -85px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-protected.tsd-is-inherited > .tsd-kind-icon:before { background-position: -102px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-class.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum > .tsd-kind-icon:before { background-position: -170px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-protected > .tsd-kind-icon:before { background-position: -187px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-enum.tsd-is-private > .tsd-kind-icon:before { background-position: -119px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface > .tsd-kind-icon:before { background-position: -204px -187px; } -.tsd-is-static.tsd-kind-event.tsd-parent-kind-interface.tsd-is-inherited > .tsd-kind-icon:before { background-position: -221px -187px; } - -.no-transition { -webkit-transition: none !important; transition: none !important; } - -@-webkit-keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } } - -@keyframes fade-in { from { opacity: 0; } - to { opacity: 1; } } -@-webkit-keyframes fade-out { - from { opacity: 1; visibility: visible; } - to { opacity: 0; } } -@keyframes fade-out { from { opacity: 1; visibility: visible; } - to { opacity: 0; } } -@-webkit-keyframes fade-in-delayed { - 0% { opacity: 0; } - 33% { opacity: 0; } - 100% { opacity: 1; } } -@keyframes fade-in-delayed { 0% { opacity: 0; } - 33% { opacity: 0; } - 100% { opacity: 1; } } -@-webkit-keyframes fade-out-delayed { - 0% { opacity: 1; visibility: visible; } - 66% { opacity: 0; } - 100% { opacity: 0; } } -@keyframes fade-out-delayed { 0% { opacity: 1; visibility: visible; } - 66% { opacity: 0; } - 100% { opacity: 0; } } -@-webkit-keyframes shift-to-left { - from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } - to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } -@keyframes shift-to-left { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); } - to { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } } -@-webkit-keyframes unshift-to-left { - from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@keyframes unshift-to-left { from { -webkit-transform: translate(-25%, 0); transform: translate(-25%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@-webkit-keyframes pop-in-from-right { - from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@keyframes pop-in-from-right { from { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } - to { -webkit-transform: translate(0, 0); transform: translate(0, 0); } } -@-webkit-keyframes pop-out-to-right { - from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } - to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } -@keyframes pop-out-to-right { from { -webkit-transform: translate(0, 0); transform: translate(0, 0); visibility: visible; } - to { -webkit-transform: translate(100%, 0); transform: translate(100%, 0); } } - -.tsd-typography { line-height: 1.333em; } -.tsd-typography ul { list-style: square; padding: 0 0 0 20px; margin: 0; } -.tsd-typography h4, .tsd-typography .tsd-index-panel h3, .tsd-index-panel .tsd-typography h3, .tsd-typography h5, .tsd-typography h6 { font-size: 1em; margin: 0; } -.tsd-typography h5, .tsd-typography h6 { font-weight: normal; } -.tsd-typography p, .tsd-typography ul, .tsd-typography ol { margin: 1em 0; } - -@media (min-width: 901px) and (max-width: 1024px) { html.default .col-content { width: 72%; } - html.default .col-menu { width: 28%; } - html.default .tsd-navigation { padding-left: 10px; } } -@media (max-width: 900px) { html.default .col-content { float: none; width: 100%; } - html.default .col-menu { position: fixed !important; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; z-index: 1024; top: 0 !important; bottom: 0 !important; left: auto !important; right: 0 !important; width: 100%; padding: 20px 20px 0 0; max-width: 450px; visibility: hidden; background-color: #fff; -webkit-transform: translate(100%, 0); -ms-transform: translate(100%, 0); transform: translate(100%, 0); } - html.default .col-menu > *:last-child { padding-bottom: 20px; } - html.default .overlay { content: ""; display: block; position: fixed; z-index: 1023; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.75); visibility: hidden; } - html.default.to-has-menu .overlay { -webkit-animation: fade-in 0.4s; animation: fade-in 0.4s; } - html.default.to-has-menu header, html.default.to-has-menu footer, html.default.to-has-menu .col-content { -webkit-animation: shift-to-left 0.4s; animation: shift-to-left 0.4s; } - html.default.to-has-menu .col-menu { -webkit-animation: pop-in-from-right 0.4s; animation: pop-in-from-right 0.4s; } - html.default.from-has-menu .overlay { -webkit-animation: fade-out 0.4s; animation: fade-out 0.4s; } - html.default.from-has-menu header, html.default.from-has-menu footer, html.default.from-has-menu .col-content { -webkit-animation: unshift-to-left 0.4s; animation: unshift-to-left 0.4s; } - html.default.from-has-menu .col-menu { -webkit-animation: pop-out-to-right 0.4s; animation: pop-out-to-right 0.4s; } - html.default.has-menu body { overflow: hidden; } - html.default.has-menu .overlay { visibility: visible; } - html.default.has-menu header, html.default.has-menu footer, html.default.has-menu .col-content { -webkit-transform: translate(-25%, 0); -ms-transform: translate(-25%, 0); transform: translate(-25%, 0); } - html.default.has-menu .col-menu { visibility: visible; -webkit-transform: translate(0, 0); -ms-transform: translate(0, 0); transform: translate(0, 0); } } - -.tsd-page-title { padding: 70px 0 20px 0; margin: 0 0 40px 0; background: #fff; box-shadow: 0 0 5px rgba(0, 0, 0, 0.35); } -.tsd-page-title h1 { margin: 0; } - -.tsd-breadcrumb { margin: 0; padding: 0; color: #808080; } -.tsd-breadcrumb a { color: #808080; text-decoration: none; } -.tsd-breadcrumb a:hover { text-decoration: underline; } -.tsd-breadcrumb li { display: inline; } -.tsd-breadcrumb li:after { content: " / "; } - -html.minimal .container-main { padding-bottom: 0; } -html.minimal .content-wrap { padding-left: 340px; } -html.minimal .tsd-navigation { position: fixed !important; float: left; overflow: auto; -webkit-overflow-scrolling: touch; overflow-scrolling: touch; box-sizing: border-box; z-index: 1; top: 60px; bottom: 0; width: 300px; padding: 20px; margin: 0; } -html.minimal .tsd-member .tsd-member { margin-left: 0; } -html.minimal .tsd-page-toolbar { position: fixed; z-index: 2; } -html.minimal #tsd-filter .tsd-filter-group { right: 0; -webkit-transform: none; -ms-transform: none; transform: none; } -html.minimal footer { background-color: transparent; } -html.minimal footer .container { padding: 0; } -html.minimal .tsd-generator { padding: 0; } -@media (max-width: 900px) { html.minimal .tsd-navigation { display: none; } - html.minimal .content-wrap { padding-left: 0; } } - -dl.tsd-comment-tags { overflow: hidden; } -dl.tsd-comment-tags dt { clear: both; float: left; padding: 1px 5px; margin: 0 10px 0 0; border-radius: 4px; border: 1px solid #808080; color: #808080; font-size: 0.8em; font-weight: normal; } -dl.tsd-comment-tags dd { margin: 0 0 10px 0; } -dl.tsd-comment-tags p { margin: 0; } - -.tsd-panel.tsd-comment .lead { font-size: 1.1em; line-height: 1.333em; margin-bottom: 2em; } -.tsd-panel.tsd-comment .lead:last-child { margin-bottom: 0; } - -.toggle-protected .tsd-is-private { display: none; } - -.toggle-public .tsd-is-private, .toggle-public .tsd-is-protected, .toggle-public .tsd-is-private-protected { display: none; } - -.toggle-inherited .tsd-is-inherited { display: none; } - -.toggle-only-exported .tsd-is-not-exported { display: none; } - -.toggle-externals .tsd-is-external { display: none; } - -#tsd-filter { position: relative; display: inline-block; height: 40px; vertical-align: bottom; } -.no-filter #tsd-filter { display: none; } -#tsd-filter .tsd-filter-group { display: inline-block; height: 40px; vertical-align: bottom; white-space: nowrap; } -#tsd-filter input { display: none; } -@media (max-width: 900px) { #tsd-filter .tsd-filter-group { display: block; position: absolute; top: 40px; right: 20px; height: auto; background-color: #fff; visibility: hidden; -webkit-transform: translate(50%, 0); -ms-transform: translate(50%, 0); transform: translate(50%, 0); box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } - .has-options #tsd-filter .tsd-filter-group { visibility: visible; } - .to-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-in 0.2s; animation: fade-in 0.2s; } - .from-has-options #tsd-filter .tsd-filter-group { -webkit-animation: fade-out 0.2s; animation: fade-out 0.2s; } - #tsd-filter label, #tsd-filter .tsd-select { display: block; padding-right: 20px; } } - -footer { background-color: #fff; } -footer.with-border-bottom { border-bottom: 1px solid #eee; margin-left: 20px } -footer .tsd-legend-group { font-size: 0; } -footer .tsd-legend { display: inline-block; width: 25%; padding: 0; font-size: 16px; list-style: none; line-height: 1.333em; vertical-align: top; } -@media (max-width: 900px) { footer .tsd-legend { width: 50%; } } - -.tsd-hierarchy { list-style: square; padding: 0 0 0 20px; margin: 0; } -.tsd-hierarchy .target { font-weight: bold; } - -.tsd-index-panel .tsd-index-content { margin-bottom: -30px !important; } -.tsd-index-panel .tsd-index-section { margin-bottom: 30px !important; } -.tsd-index-panel h3 { margin: 0 -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } -.tsd-index-panel ul.tsd-index-list { -webkit-column-count: 3; -moz-column-count: 3; -ms-column-count: 3; column-count: 3; -webkit-column-gap: 20px; -moz-column-gap: 20px; -ms-column-gap: 20px; column-gap: 20px; padding: 0; list-style: none; line-height: 1.333em; } -@media (max-width: 900px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 1; -moz-column-count: 1; -ms-column-count: 1; column-count: 1; } } -@media (min-width: 901px) and (max-width: 1024px) { .tsd-index-panel ul.tsd-index-list { -webkit-column-count: 2; -moz-column-count: 2; -ms-column-count: 2; column-count: 2; } } -.tsd-index-panel ul.tsd-index-list li { -webkit-column-break-inside: avoid; -moz-column-break-inside: avoid; -ms-column-break-inside: avoid; -o-column-break-inside: avoid; column-break-inside: avoid; -webkit-page-break-inside: avoid; -moz-page-break-inside: avoid; -ms-page-break-inside: avoid; -o-page-break-inside: avoid; page-break-inside: avoid; } -.tsd-index-panel a, .tsd-index-panel .tsd-parent-kind-module a { color: #9600ff; } -.tsd-index-panel .tsd-parent-kind-interface a { color: #7da01f; } -.tsd-index-panel .tsd-parent-kind-enum a { color: #cc9900; } -.tsd-index-panel .tsd-parent-kind-class a { color: #4da6ff; } -.tsd-index-panel .tsd-kind-module a { color: #9600ff; } -.tsd-index-panel .tsd-kind-interface a { color: #7da01f; } -.tsd-index-panel .tsd-kind-enum a { color: #cc9900; } -.tsd-index-panel .tsd-kind-class a { color: #4da6ff; } -.tsd-index-panel .tsd-is-private a { color: #808080; } - -.tsd-flag { display: inline-block; padding: 1px 5px; border-radius: 4px; color: #fff; background-color: #808080; text-indent: 0; font-size: 14px; font-weight: normal; } - -.tsd-anchor { position: absolute; top: -100px; } - -.tsd-member { position: relative; } -.tsd-member .tsd-anchor + h3 { margin-top: 0; margin-bottom: 0; border-bottom: none; } - -.tsd-navigation { padding: 0 0 0 40px; } -.tsd-navigation a { display: block; padding-top: 2px; padding-bottom: 2px; border-left: 2px solid transparent; color: #222; text-decoration: none; -webkit-transition: border-left-color 0.1s; transition: border-left-color 0.1s; } -.tsd-navigation a:hover { text-decoration: underline; } -.tsd-navigation ul { margin: 0; padding: 0; list-style: none; } -.tsd-navigation li { padding: 0; } - -.tsd-navigation.primary { padding-bottom: 40px; } -.tsd-navigation.primary a { display: block; padding-top: 6px; padding-bottom: 6px; } -.tsd-navigation.primary ul li a { padding-left: 5px; } -.tsd-navigation.primary ul li li a { padding-left: 25px; } -.tsd-navigation.primary ul li li li a { padding-left: 45px; } -.tsd-navigation.primary ul li li li li a { padding-left: 65px; } -.tsd-navigation.primary ul li li li li li a { padding-left: 85px; } -.tsd-navigation.primary ul li li li li li li a { padding-left: 105px; } -.tsd-navigation.primary > ul { border-bottom: 1px solid #eee; } -.tsd-navigation.primary li { border-top: 1px solid #eee; } -.tsd-navigation.primary li.current > a { font-weight: bold; } -.tsd-navigation.primary li.label span { display: block; padding: 20px 0 6px 5px; color: #808080; } -.tsd-navigation.primary li.globals + li > span, .tsd-navigation.primary li.globals + li > a { padding-top: 20px; } - -.tsd-navigation.secondary ul { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -.tsd-navigation.secondary ul li a { padding-left: 25px; } -.tsd-navigation.secondary ul li li a { padding-left: 45px; } -.tsd-navigation.secondary ul li li li a { padding-left: 65px; } -.tsd-navigation.secondary ul li li li li a { padding-left: 85px; } -.tsd-navigation.secondary ul li li li li li a { padding-left: 105px; } -.tsd-navigation.secondary ul li li li li li li a { padding-left: 125px; } -.tsd-navigation.secondary ul.current a { border-left-color: #eee; } -.tsd-navigation.secondary li.focus > a, .tsd-navigation.secondary ul.current li.focus > a { border-left-color: #000; } -.tsd-navigation.secondary li.current { margin-top: 20px; margin-bottom: 20px; border-left-color: #eee; } -.tsd-navigation.secondary li.current > a { font-weight: bold; } - -@media (min-width: 901px) { .menu-sticky-wrap { position: static; } - .no-csspositionsticky .menu-sticky-wrap.sticky { position: fixed; } - .no-csspositionsticky .menu-sticky-wrap.sticky-current { position: fixed; } - .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.before-current, .no-csspositionsticky .menu-sticky-wrap.sticky-current ul.after-current { opacity: 0; } - .no-csspositionsticky .menu-sticky-wrap.sticky-bottom { position: absolute; top: auto !important; left: auto !important; bottom: 0; right: 0; } - .csspositionsticky .menu-sticky-wrap.sticky { position: -webkit-sticky; position: sticky; } - .csspositionsticky .menu-sticky-wrap.sticky-current { position: -webkit-sticky; position: sticky; } } - -.tsd-panel { margin: 20px 0; padding: 20px; background-color: #fff; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } -.tsd-panel:empty { display: none; } -.tsd-panel > h1, .tsd-panel > h2, .tsd-panel > h3 { margin: 1.5em -20px 10px -20px; padding: 0 20px 10px 20px; border-bottom: 1px solid #eee; } -.tsd-panel > h1.tsd-before-signature, .tsd-panel > h2.tsd-before-signature, .tsd-panel > h3.tsd-before-signature { margin-bottom: 0; border-bottom: 0; } -.tsd-panel table { display: block; width: 100%; overflow: auto; margin-top: 10px; word-break: normal; word-break: keep-all; } -.tsd-panel table th { font-weight: bold; } -.tsd-panel table th, .tsd-panel table td { padding: 6px 13px; border: 1px solid #ddd; } -.tsd-panel table tr { background-color: #fff; border-top: 1px solid #ccc; } -.tsd-panel table tr:nth-child(2n) { background-color: #f8f8f8; } - -.tsd-panel-group { margin: 30px 0; } -.tsd-panel-group > h1, .tsd-panel-group > h2, .tsd-panel-group > h3 { padding-left: 20px; padding-right: 20px; } - -#tsd-search { -webkit-transition: background-color 0.2s; transition: background-color 0.2s; } -#tsd-search .title { position: relative; z-index: 2; } -#tsd-search .field { position: absolute; left: 0; top: 0; right: 40px; height: 40px; } -#tsd-search .field input { box-sizing: border-box; position: relative; top: -50px; z-index: 1; width: 100%; padding: 0 10px; opacity: 0; outline: 0; border: 0; background: transparent; color: #222; } -#tsd-search .field label { position: absolute; overflow: hidden; right: -40px; } -#tsd-search .field input, #tsd-search .title { -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -#tsd-search .results { position: absolute; visibility: hidden; top: 40px; width: 100%; margin: 0; padding: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); } -#tsd-search .results li { padding: 0 10px; background-color: #fdfdfd; } -#tsd-search .results li:nth-child(even) { background-color: #fff; } -#tsd-search .results li.state { display: none; } -#tsd-search .results li.current, #tsd-search .results li:hover { background-color: #eee; } -#tsd-search .results a { display: block; } -#tsd-search .results a:before { top: 10px; } -#tsd-search .results span.parent { color: #808080; font-weight: normal; } -#tsd-search.has-focus { background-color: #eee; } -#tsd-search.has-focus .field input { top: 0; opacity: 1; } -#tsd-search.has-focus .title { z-index: 0; opacity: 0; } -#tsd-search.has-focus .results { visibility: visible; } -#tsd-search.loading .results li.state.loading { display: block; } -#tsd-search.failure .results li.state.failure { display: block; } - -.tsd-signature { margin: 0 0 1em 0; padding: 10px; border: 1px solid #eee; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; font-size: 14px; } -.tsd-signature.tsd-kind-icon { padding-left: 30px; } -.tsd-signature.tsd-kind-icon:before { top: 10px; left: 10px; } -.tsd-panel > .tsd-signature { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } -.tsd-panel > .tsd-signature.tsd-kind-icon { padding-left: 40px; } -.tsd-panel > .tsd-signature.tsd-kind-icon:before { left: 20px; } - -.tsd-signature-symbol { color: #808080; font-weight: normal; } - -.tsd-signature-type { font-style: italic; font-weight: normal; } - -.tsd-signatures { padding: 0; margin: 0 0 1em 0; border: 1px solid #eee; } -.tsd-signatures .tsd-signature { margin: 0; border-width: 1px 0 0 0; -webkit-transition: background-color 0.1s; transition: background-color 0.1s; } -.tsd-signatures .tsd-signature:first-child { border-top-width: 0; } -.tsd-signatures .tsd-signature.current { background-color: #eee; } -.tsd-signatures.active > .tsd-signature { cursor: pointer; } -.tsd-panel > .tsd-signatures { margin-left: -20px; margin-right: -20px; border-width: 1px 0; } -.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon { padding-left: 40px; } -.tsd-panel > .tsd-signatures .tsd-signature.tsd-kind-icon:before { left: 20px; } -.tsd-panel > a.anchor + .tsd-signatures { border-top-width: 0; margin-top: -20px; } - -ul.tsd-descriptions { position: relative; overflow: hidden; -webkit-transition: height 0.3s; transition: height 0.3s; padding: 0; list-style: none; } -ul.tsd-descriptions.active > .tsd-description { display: none; } -ul.tsd-descriptions.active > .tsd-description.current { display: block; } -ul.tsd-descriptions.active > .tsd-description.fade-in { -webkit-animation: fade-in-delayed 0.3s; animation: fade-in-delayed 0.3s; } -ul.tsd-descriptions.active > .tsd-description.fade-out { -webkit-animation: fade-out-delayed 0.3s; animation: fade-out-delayed 0.3s; position: absolute; display: block; top: 0; left: 0; right: 0; opacity: 0; visibility: hidden; } -ul.tsd-descriptions h4, ul.tsd-descriptions .tsd-index-panel h3, .tsd-index-panel ul.tsd-descriptions h3 { font-size: 16px; margin: 1em 0 0.5em 0; } - -ul.tsd-parameters, ul.tsd-type-parameters { list-style: square; margin: 0; padding-left: 20px; } -ul.tsd-parameters > li.tsd-parameter-siganture, ul.tsd-type-parameters > li.tsd-parameter-siganture { list-style: none; margin-left: -20px; } -ul.tsd-parameters h5, ul.tsd-type-parameters h5 { font-size: 16px; margin: 1em 0 0.5em 0; } -ul.tsd-parameters .tsd-comment, ul.tsd-type-parameters .tsd-comment { margin-top: -0.5em; } - -.tsd-sources { font-size: 14px; color: #808080; margin: 0 0 1em 0; } -.tsd-sources a { color: #808080; text-decoration: underline; } -.tsd-sources ul, .tsd-sources p { margin: 0 !important; } -.tsd-sources ul { list-style: none; padding: 0; } - -.tsd-page-toolbar { position: absolute; z-index: 1; top: 0; left: 0; width: 100%; height: 40px; color: #333; background: #fff; border-bottom: 1px solid #eee; } -.tsd-page-toolbar a { color: #333; text-decoration: none; } -.tsd-page-toolbar a.title { font-weight: bold; } -.tsd-page-toolbar a.title:hover { text-decoration: underline; } -.tsd-page-toolbar .table-wrap { display: table; width: 100%; height: 40px; } -.tsd-page-toolbar .table-cell { display: table-cell; position: relative; white-space: nowrap; line-height: 40px; } -.tsd-page-toolbar .table-cell:first-child { width: 100%; } - -.tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { content: ""; display: inline-block; width: 40px; height: 40px; margin: 0 -8px 0 0; background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAoCAQAAAAlSeuiAAABp0lEQVR4Ae3aUa3jQAyF4QNhIBTCQiiEQlgIhRAGhTAQBkIgBEIgDITZZGXNjZTePiSWYqn/54dGfbAq+SiTutWXAgAAAAAAAAAAAAA8NCz1UFSD2lKDS5d3NVzZj/BVNasaLoRZRUmj2lLrVVHWMUntQ13Wj/i1pWa9lprX6xMRnH4dx6Rjsn26+v+12ms+EcB37P0r+qH+DNQGXgMFcHzbregQ78B8eQCTJk0e979ZW7PdA2O49ceDsYexKgUNoI3EKYDWL3D8miaPh/uXtl6BHqEHFQvgXau/FsCiIWAAbST2fpQRT0sl70j3z5ZiBdD7CG5WZX8kxwmgjbiP5GQA9/3O2XaxnnHi53AEE0AbRh+JQwC3/fzC4hcb6xPvS4i3QaMdwX+0utsRPEY6gm2wNhKHAG77eUi7SIcK4G4NY4GMIan2u2Cxqzncl5DUn7Q8ArjvZ8JFOsl/Ed0jyBom+BomQKSto+9PcblHMM4iuu4X0QQw5hrGQY/gUxFkjZuf4m4alXVU+1De/VhEn5CvDSB/RsBzqWgAAAAAAAAAAAAAAACAfyyYJ5nhVuwIAAAAAElFTkSuQmCC); background-repeat: no-repeat; text-indent: -1024px; vertical-align: bottom; } -@media (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) { .tsd-widget:before, .tsd-select .tsd-select-label:before, .tsd-select .tsd-select-list li:before { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAABQCAMAAAC+sjQXAAAAM1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjBUbJAAAAEXRSTlMA3/+/UCBw7xCPYIBAMM+vn1qYQ7QAAALCSURBVHgB7MGBAAAAAICg/akXqQIAAAAAAAAAAAAAAAAAAJids9mdE4bhoDNZCITP93/aSmhV/9uwPWyi8jtkblws2IxsYpz9LwSAaJW8AreE16PxOsMYE6Q4DiYKF7X+8ZHXc/E608xv5snEyIuZrVwMZjbnujR6T3gsXmcLOIRNzD+Ig2UuVtt2+NbAiX/wVLzOlviD9L2BOfGBlL/3D1I+uDjGBJArBPxU3x+K15kCQFo2s21JAOHrKpz4SPrWv4IKA+uFaR6vMwMcb+emA2DWEfDglrkLqEBOKVslA8Dx14oPMiV4CtywWxdQgAwkq2QE0uTXUwJGk2G9s3mTFNBzAkC7HKPsX72AEVjMnAWIpsPCRRjXdQxcjCYpoOcEgHY5Rtk/slWSgM3M2aSeeVgjAOeVpKcdgGMdNAXMuIAqOcZzqF8L+WcAsi8wkTeheCWMegL6mgCorHHyEJ5TVfxrLWDrTUjZdhnhjYqAnlN8TaoELOLVC0gucmoz/3RKcPs2jAs4+J5ET8AEZF+TSgGLeC1V8YuGQQU2IV1Asq9JCwE9XitZVPxr34bpJRj8PqsFLOK108W9aVrWZRrR7Sm2HL4JCToCujHZ6gUs4jUz0P1TEvD+U5wMa363YeziBODIq1YbJrsv9QKW8Ry1nNp+GAHvuingRTfmYcjBf0QpAS37bdUL6PFKtHJq63EsZ5cxcKMkDVIClu1dAK1PcJ5TFQ0M9wZKDCPs3BD7MIJGTs3WfiTfDVQYx5q5ZekCauTU3P5Q0ukGCgh49oFURdobWBY9N/CxEuwGjpGLuPhTdwH1x7HqDDxNgRP2zQ8lraFyF/yJ9vH6QGqtgSbBOU8/j2VORz+Wqfle2d5Ae4R+ML0z7Y+W4P7XHN3AU+tzyK/24EAGAAAAYJC/9T2+CgAAAAAAAAAAAAAAAAAAAADgJpfzHyIKFFBKAAAAAElFTkSuQmCC); background-size: 320px 40px; } } - -.tsd-widget { display: inline-block; overflow: hidden; opacity: 0.6; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } -.tsd-widget:hover { opacity: 0.8; } -.tsd-widget.active { opacity: 1; background-color: #eee; } -.tsd-widget.no-caption { width: 40px; } -.tsd-widget.no-caption:before { margin: 0; } -.tsd-widget.search:before { background-position: 0 0; } -.tsd-widget.menu:before { background-position: -40px 0; } -.tsd-widget.options:before { background-position: -80px 0; } -.tsd-widget.options, .tsd-widget.menu { display: none; } -@media (max-width: 900px) { .tsd-widget.options, .tsd-widget.menu { display: inline-block; } } -input[type=checkbox] + .tsd-widget:before { background-position: -120px 0; } -input[type=checkbox]:checked + .tsd-widget:before { background-position: -160px 0; } - -.tsd-select { position: relative; display: inline-block; height: 40px; -webkit-transition: opacity 0.1s, background-color 0.2s; transition: opacity 0.1s, background-color 0.2s; vertical-align: bottom; cursor: pointer; } -.tsd-select .tsd-select-label { opacity: 0.6; -webkit-transition: opacity 0.2s; transition: opacity 0.2s; } -.tsd-select .tsd-select-label:before { background-position: -240px 0; } -.tsd-select.active .tsd-select-label { opacity: 0.8; } -.tsd-select.active .tsd-select-list { visibility: visible; opacity: 1; -webkit-transition-delay: 0s; transition-delay: 0s; } -.tsd-select .tsd-select-list { position: absolute; visibility: hidden; top: 40px; left: 0; margin: 0; padding: 0; opacity: 0; list-style: none; box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); -webkit-transition: visibility 0s 0.2s, opacity 0.2s; transition: visibility 0s 0.2s, opacity 0.2s; } -.tsd-select .tsd-select-list li { padding: 0 20px 0 0; background-color: #fdfdfd; } -.tsd-select .tsd-select-list li:before { background-position: 40px 0; } -.tsd-select .tsd-select-list li:nth-child(even) { background-color: #fff; } -.tsd-select .tsd-select-list li:hover { background-color: #eee; } -.tsd-select .tsd-select-list li.selected:before { background-position: -200px 0; } -@media (max-width: 900px) { .tsd-select .tsd-select-list { top: 0; left: auto; right: 100%; margin-right: -5px; } - .tsd-select .tsd-select-label:before { background-position: -280px 0; } } diff --git a/docs/css/extra.css b/docs/css/extra.css deleted file mode 100644 index 804bb597f..000000000 --- a/docs/css/extra.css +++ /dev/null @@ -1,14 +0,0 @@ -pre code { - white-space: pre; - word-wrap: normal; - display: block; - padding: 12px; - font-size: 14px; -} - -code { - white-space: pre-wrap; - word-wrap: break-word; - padding: 2px 5px; - font-size: 16px; -} diff --git a/docs/css/github-highlight.css b/docs/css/github-highlight.css deleted file mode 100644 index 52b18879c..000000000 --- a/docs/css/github-highlight.css +++ /dev/null @@ -1,224 +0,0 @@ -pre { - border: none !important; - background-color: #fff !important; -} -code { - color: inherit !important; - background-color: #fff; -} -pre, code { - white-space: pre !important; -} -.highlight table pre { margin: 0; } -.highlight .cm { - color: #999988; - font-style: italic; -} -.highlight .cp { - color: #999999; - font-weight: bold; -} -.highlight .c1 { - color: #999988; - font-style: italic; -} -.highlight .cs { - color: #999999; - font-weight: bold; - font-style: italic; -} -.highlight .c, .highlight .cd { - color: #999988; - font-style: italic; -} -.highlight .err { - color: #a61717; - background-color: #e3d2d2; -} -.highlight .gd { - color: #000000; - background-color: #ffdddd; -} -.highlight .ge { - color: #000000; - font-style: italic; -} -.highlight .gr { - color: #aa0000; -} -.highlight .gh { - color: #999999; -} -.highlight .gi { - color: #000000; - background-color: #ddffdd; -} -.highlight .go { - color: #888888; -} -.highlight .gp { - color: #555555; -} -.highlight .gs { - font-weight: bold; -} -.highlight .gu { - color: #aaaaaa; -} -.highlight .gt { - color: #aa0000; -} -.highlight .kc { - color: #000000; - font-weight: bold; -} -.highlight .kd { - color: #000000; - font-weight: bold; -} -.highlight .kn { - color: #000000; - font-weight: bold; -} -.highlight .kp { - color: #000000; - font-weight: bold; -} -.highlight .kr { - color: #000000; - font-weight: bold; -} -.highlight .kt { - color: #445588; - font-weight: bold; -} -.highlight .k, .highlight .kv { - color: #000000; - font-weight: bold; -} -.highlight .mf { - color: #009999; -} -.highlight .mh { - color: #009999; -} -.highlight .il { - color: #009999; -} -.highlight .mi { - color: #009999; -} -.highlight .mo { - color: #009999; -} -.highlight .m, .highlight .mb, .highlight .mx { - color: #009999; -} -.highlight .sb { - color: #d14; -} -.highlight .sc { - color: #d14; -} -.highlight .sd { - color: #d14; -} -.highlight .s2 { - color: #d14; -} -.highlight .se { - color: #d14; -} -.highlight .sh { - color: #d14; -} -.highlight .si { - color: #d14; -} -.highlight .sx { - color: #d14; -} -.highlight .sr { - color: #009926; -} -.highlight .s1 { - color: #d14; -} -.highlight .ss { - color: #990073; -} -.highlight .s { - color: #d14; -} -.highlight .na { - color: #008080; -} -.highlight .bp { - color: #999999; -} -.highlight .nb { - color: #0086B3; -} -.highlight .nc { - color: #445588; - font-weight: bold; -} -.highlight .no { - color: #008080; -} -.highlight .nd { - color: #3c5d5d; - font-weight: bold; -} -.highlight .ni { - color: #800080; -} -.highlight .ne { - color: #990000; - font-weight: bold; -} -.highlight .nf { - color: #990000; - font-weight: bold; -} -.highlight .nl { - color: #990000; - font-weight: bold; -} -.highlight .nn { - color: #555555; -} -.highlight .nt { - color: #000080; -} -.highlight .vc { - color: #008080; -} -.highlight .vg { - color: #008080; -} -.highlight .vi { - color: #008080; -} -.highlight .nv { - color: #008080; -} -.highlight .ow { - color: #000000; - font-weight: bold; -} -.highlight .o { - color: #000000; - font-weight: bold; -} -.highlight .w { - color: #bbbbbb; -} -.highlight { - background-color: #fff; - padding-bottom: 0px; -} -.highlighter-rouge { - border: 1px solid #ccc; - margin-bottom: 1em; -} diff --git a/docs/css/main.css b/docs/css/main.css deleted file mode 100644 index 05fd446ec..000000000 --- a/docs/css/main.css +++ /dev/null @@ -1,276 +0,0 @@ -/* General CSS */ -@import url(https://fonts.googleapis.com/css?family=Open+Sans); - -body { - font-family: "Open Sans", Helvetica, Arial, sans-serif; -} - -img { - max-width: 90%; -} - -/* Custom CSS for home page */ -.hero-spacer { - margin-top: 50px; -} - -.hero-feature { - margin-bottom: 30px; -} - -.blog-content-wrap { - margin-top: 20px; - padding-top: 40px; -} - -/* Custom CSS for the docs */ - -.edit-links { - color: #cccccc; -} - -/* Prevent in-page links from scrolling under top nav */ -h2::before, h3::before { - display: block; - content: " "; - margin-top: -80px; - height: 80px; - visibility: hidden; -} - -/* Custom CSS for header and footer */ -.site-header -{ - width: 100%; - padding: 24px 0px; -} - -img.logo -{ - height: 28px; - margin-left: -18px; -} - -.icon > svg -{ - display: inline-block; - width: 28px; - height: 28px; -} - -.icon > svg path -{ - fill: #333333; -} - - -.thumb-home { - height: 340px; -} - -.img-home { - height: 150px !important; - margin: 10px; -} - -.tableauIcon { - margin-left: 18px; -} - -.jumbotron { - background-color: #fafafa; - border: 1px solid #e8e8e8; -} - -#community-jumbo { - background-color: #F0F8FF; -} - -footer { - margin: 30px 0; - text-align: center; -} - -.footer-hr { - width: 100%; - position: relative; -} - -table { - border: 1px solid #c2c2c2; - border-collapse: collapse; - margin: 1em 0px; -} - -th -{ - font-weight: bold; - border: 1px solid #c2c2c2; - text-align: left; - padding: .3em; - vertical-align: top; - background-color: #fafafa; -} - -td -{ - border: 1px solid #c2c2c2; - text-align: left; - padding: .3em; - vertical-align: top; -} - -/* So the scroll bar width doesn't cause the page to jump */ -html { - overflow-y: scroll; -} - -.label { - margin-right: 3px; -} - -/* to get right navbar icons to respect collapse */ -@media (max-width: 992px) { - .navbar-header { - float: none; - } - .navbar-left,.navbar-right { - float: none !important; - } - .navbar-toggle { - display: block; - } - .navbar-collapse { - border-top: 1px solid transparent; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.1); - } - .navbar-fixed-top { - top: 0; - border-width: 0 0 1px; - } - .navbar-collapse.collapse { - display: none!important; - } - .navbar-nav { - float: none!important; - margin-top: 7.5px; - } - .navbar-nav>li { - float: none; - } - .navbar-nav>li>a { - padding-top: 10px; - padding-bottom: 10px; - } - .collapse.in{ - display:block !important; - } -} - - -/* Custom css for news section */ -.blog-content { - margin-bottom: 70px; -} - -.blogul { - padding: 0px; -} - -.blogul h1 { - margin-top: 40px; -} - -.blog-content > h4 { - margin-top: 20px; -} - -.blog-content > ol { - margin-bottom: 20px; -} - - -/* Community connectors */ -.thumbnail { - background-color: #fff; - border: 1px solid #ccc; - margin: 12px; -} - -.thumbnail h2 { - border-left: 2px solid #337ab7; - text-decoration: none; - font-size: 20px; - padding: 6px; - margin-left: 10px; -} - -.thumbnail h2 a { - text-decoration: none; - color: #333333; -} - -.well { - background-color: #ffffff; - margin-bottom: 40px; -} - -.tsd-navigation { - padding: 10px !important; -} - -/* Media queries for responsive design */ -#grid[data-columns]::before { - content: '3 .column.size-1of3'; -} - -.column { float: left; } -.size-1of3 { width: 33.333%; } - - -@media screen and (max-width: 767px) { - #grid[data-columns]::before { - content: '1 .column.size-1of1'; - } - - /* Docs Menu*/ - .docs-menu, .tsd-navigation { - top: 20px; - position: relative; - } - -} -@media screen and (min-width: 769px) { - #grid[data-columns]::before { - content: '3 .column.size-1of3'; - } - - /* Docs Menu*/ - .docs-menu, .tsd-navigation { - position: fixed; - overflow: auto; - top: 90px; - max-height: 90%; - max-width: 250px; - } - - .content { - position: relative; - margin: 40px 0px 0px 275px; - max-width: 1000px; - } - - /* API Reference */ - - .ref-content { - margin: 40px 0px 0px 275px; - max-width: 1000px; - } -} - -.column { float: left; } -.size-1of1 { width: 100%; } -.size-1of2 { width: 50%; } -.size-1of3 { width: 33.333%; } - - diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index c0f9a4431..000000000 --- a/docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: home -indexed_by_search: false ---- - -
-

Tableau Server Client (Python)

-

The Tableau Server Client is a Python library for the Tableau Server REST API.

-
- Get Started   - Download -
- diff --git a/docs/js/lunr.min.js b/docs/js/lunr.min.js deleted file mode 100644 index 22776bb85..000000000 --- a/docs/js/lunr.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 0.7.2 - * Copyright (C) 2016 Oliver Nightingale - * @license MIT - */ -!function(){var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.7.2",t.utils={},t.utils.warn=function(t){return function(e){t.console&&console.warn&&console.warn(e)}}(this),t.utils.asString=function(t){return void 0===t||null===t?"":t.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var t=Array.prototype.slice.call(arguments),e=t.pop(),n=t;if("function"!=typeof e)throw new TypeError("last argument must be a function");n.forEach(function(t){this.hasHandler(t)||(this.events[t]=[]),this.events[t].push(e)},this)},t.EventEmitter.prototype.removeListener=function(t,e){if(this.hasHandler(t)){var n=this.events[t].indexOf(e);this.events[t].splice(n,1),this.events[t].length||delete this.events[t]}},t.EventEmitter.prototype.emit=function(t){if(this.hasHandler(t)){var e=Array.prototype.slice.call(arguments,1);this.events[t].forEach(function(t){t.apply(void 0,e)})}},t.EventEmitter.prototype.hasHandler=function(t){return t in this.events},t.tokenizer=function(e){if(!arguments.length||null==e||void 0==e)return[];if(Array.isArray(e))return e.map(function(e){return t.utils.asString(e).toLowerCase()});var n=t.tokenizer.seperator||t.tokenizer.separator;return e.toString().trim().toLowerCase().split(n)},t.tokenizer.seperator=!1,t.tokenizer.separator=/[\s\-]+/,t.tokenizer.load=function(t){var e=this.registeredFunctions[t];if(!e)throw new Error("Cannot load un-registered function: "+t);return e},t.tokenizer.label="default",t.tokenizer.registeredFunctions={"default":t.tokenizer},t.tokenizer.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing tokenizer: "+n),e.label=n,this.registeredFunctions[n]=e},t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.registeredFunctions[e];if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._stack.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");i+=1,this._stack.splice(i,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");this._stack.splice(i,0,n)},t.Pipeline.prototype.remove=function(t){var e=this._stack.indexOf(t);-1!=e&&this._stack.splice(e,1)},t.Pipeline.prototype.run=function(t){for(var e=[],n=t.length,i=this._stack.length,r=0;n>r;r++){for(var o=t[r],s=0;i>s&&(o=this._stack[s](o,r,t),void 0!==o&&""!==o);s++);void 0!==o&&""!==o&&e.push(o)}return e},t.Pipeline.prototype.reset=function(){this._stack=[]},t.Pipeline.prototype.toJSON=function(){return this._stack.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Vector=function(){this._magnitude=null,this.list=void 0,this.length=0},t.Vector.Node=function(t,e,n){this.idx=t,this.val=e,this.next=n},t.Vector.prototype.insert=function(e,n){this._magnitude=void 0;var i=this.list;if(!i)return this.list=new t.Vector.Node(e,n,i),this.length++;if(en.idx?n=n.next:(i+=e.val*n.val,e=e.next,n=n.next);return i},t.Vector.prototype.similarity=function(t){return this.dot(t)/(this.magnitude()*t.magnitude())},t.SortedSet=function(){this.length=0,this.elements=[]},t.SortedSet.load=function(t){var e=new this;return e.elements=t,e.length=t.length,e},t.SortedSet.prototype.add=function(){var t,e;for(t=0;t1;){if(o===t)return r;t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r]}return o===t?r:-1},t.SortedSet.prototype.locationFor=function(t){for(var e=0,n=this.elements.length,i=n-e,r=e+Math.floor(i/2),o=this.elements[r];i>1;)t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r];return o>t?r:t>o?r+1:void 0},t.SortedSet.prototype.intersect=function(e){for(var n=new t.SortedSet,i=0,r=0,o=this.length,s=e.length,a=this.elements,h=e.elements;;){if(i>o-1||r>s-1)break;a[i]!==h[r]?a[i]h[r]&&r++:(n.add(a[i]),i++,r++)}return n},t.SortedSet.prototype.clone=function(){var e=new t.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},t.SortedSet.prototype.union=function(t){var e,n,i;this.length>=t.length?(e=this,n=t):(e=t,n=this),i=e.clone();for(var r=0,o=n.toArray();rp;p++)c[p]===a&&d++;h+=d/f*l.boost}}this.tokenStore.add(a,{ref:o,tf:h})}n&&this.eventEmitter.emit("add",e,this)},t.Index.prototype.remove=function(t,e){var n=t[this._ref],e=void 0===e?!0:e;if(this.documentStore.has(n)){var i=this.documentStore.get(n);this.documentStore.remove(n),i.forEach(function(t){this.tokenStore.remove(t,n)},this),e&&this.eventEmitter.emit("remove",t,this)}},t.Index.prototype.update=function(t,e){var e=void 0===e?!0:e;this.remove(t,!1),this.add(t,!1),e&&this.eventEmitter.emit("update",t,this)},t.Index.prototype.idf=function(t){var e="@"+t;if(Object.prototype.hasOwnProperty.call(this._idfCache,e))return this._idfCache[e];var n=this.tokenStore.count(t),i=1;return n>0&&(i=1+Math.log(this.documentStore.length/n)),this._idfCache[e]=i},t.Index.prototype.search=function(e){var n=this.pipeline.run(this.tokenizerFn(e)),i=new t.Vector,r=[],o=this._fields.reduce(function(t,e){return t+e.boost},0),s=n.some(function(t){return this.tokenStore.has(t)},this);if(!s)return[];n.forEach(function(e,n,s){var a=1/s.length*this._fields.length*o,h=this,u=this.tokenStore.expand(e).reduce(function(n,r){var o=h.corpusTokens.indexOf(r),s=h.idf(r),u=1,l=new t.SortedSet;if(r!==e){var c=Math.max(3,r.length-e.length);u=1/Math.log(c)}o>-1&&i.insert(o,a*s*u);for(var f=h.tokenStore.get(r),d=Object.keys(f),p=d.length,v=0;p>v;v++)l.add(f[d[v]].ref);return n.union(l)},new t.SortedSet);r.push(u)},this);var a=r.reduce(function(t,e){return t.intersect(e)});return a.map(function(t){return{ref:t,score:i.similarity(this.documentVector(t))}},this).sort(function(t,e){return e.score-t.score})},t.Index.prototype.documentVector=function(e){for(var n=this.documentStore.get(e),i=n.length,r=new t.Vector,o=0;i>o;o++){var s=n.elements[o],a=this.tokenStore.get(s)[e].tf,h=this.idf(s);r.insert(this.corpusTokens.indexOf(s),a*h)}return r},t.Index.prototype.toJSON=function(){return{version:t.version,fields:this._fields,ref:this._ref,tokenizer:this.tokenizerFn.label,documentStore:this.documentStore.toJSON(),tokenStore:this.tokenStore.toJSON(),corpusTokens:this.corpusTokens.toJSON(),pipeline:this.pipeline.toJSON()}},t.Index.prototype.use=function(t){var e=Array.prototype.slice.call(arguments,1);e.unshift(this),t.apply(this,e)},t.Store=function(){this.store={},this.length=0},t.Store.load=function(e){var n=new this;return n.length=e.length,n.store=Object.keys(e.store).reduce(function(n,i){return n[i]=t.SortedSet.load(e.store[i]),n},{}),n},t.Store.prototype.set=function(t,e){this.has(t)||this.length++,this.store[t]=e},t.Store.prototype.get=function(t){return this.store[t]},t.Store.prototype.has=function(t){return t in this.store},t.Store.prototype.remove=function(t){this.has(t)&&(delete this.store[t],this.length--)},t.Store.prototype.toJSON=function(){return{store:this.store,length:this.length}},t.stemmer=function(){var t={ational:"ate",tional:"tion",enci:"ence",anci:"ance",izer:"ize",bli:"ble",alli:"al",entli:"ent",eli:"e",ousli:"ous",ization:"ize",ation:"ate",ator:"ate",alism:"al",iveness:"ive",fulness:"ful",ousness:"ous",aliti:"al",iviti:"ive",biliti:"ble",logi:"log"},e={icate:"ic",ative:"",alize:"al",iciti:"ic",ical:"ic",ful:"",ness:""},n="[^aeiou]",i="[aeiouy]",r=n+"[^aeiouy]*",o=i+"[aeiou]*",s="^("+r+")?"+o+r,a="^("+r+")?"+o+r+"("+o+")?$",h="^("+r+")?"+o+r+o+r,u="^("+r+")?"+i,l=new RegExp(s),c=new RegExp(h),f=new RegExp(a),d=new RegExp(u),p=/^(.+?)(ss|i)es$/,v=/^(.+?)([^s])s$/,g=/^(.+?)eed$/,m=/^(.+?)(ed|ing)$/,y=/.$/,S=/(at|bl|iz)$/,w=new RegExp("([^aeiouylsz])\\1$"),k=new RegExp("^"+r+i+"[^aeiouwxy]$"),x=/^(.+?[^aeiou])y$/,b=/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/,E=/^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/,F=/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/,_=/^(.+?)(s|t)(ion)$/,z=/^(.+?)e$/,O=/ll$/,P=new RegExp("^"+r+i+"[^aeiouwxy]$"),T=function(n){var i,r,o,s,a,h,u;if(n.length<3)return n;if(o=n.substr(0,1),"y"==o&&(n=o.toUpperCase()+n.substr(1)),s=p,a=v,s.test(n)?n=n.replace(s,"$1$2"):a.test(n)&&(n=n.replace(a,"$1$2")),s=g,a=m,s.test(n)){var T=s.exec(n);s=l,s.test(T[1])&&(s=y,n=n.replace(s,""))}else if(a.test(n)){var T=a.exec(n);i=T[1],a=d,a.test(i)&&(n=i,a=S,h=w,u=k,a.test(n)?n+="e":h.test(n)?(s=y,n=n.replace(s,"")):u.test(n)&&(n+="e"))}if(s=x,s.test(n)){var T=s.exec(n);i=T[1],n=i+"i"}if(s=b,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+t[r])}if(s=E,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+e[r])}if(s=F,a=_,s.test(n)){var T=s.exec(n);i=T[1],s=c,s.test(i)&&(n=i)}else if(a.test(n)){var T=a.exec(n);i=T[1]+T[2],a=c,a.test(i)&&(n=i)}if(s=z,s.test(n)){var T=s.exec(n);i=T[1],s=c,a=f,h=P,(s.test(i)||a.test(i)&&!h.test(i))&&(n=i)}return s=O,a=c,s.test(n)&&a.test(n)&&(s=y,n=n.replace(s,"")),"y"==o&&(n=o.toLowerCase()+n.substr(1)),n};return T}(),t.Pipeline.registerFunction(t.stemmer,"stemmer"),t.generateStopWordFilter=function(t){var e=t.reduce(function(t,e){return t[e]=e,t},{});return function(t){return t&&e[t]!==t?t:void 0}},t.stopWordFilter=t.generateStopWordFilter(["a","able","about","across","after","all","almost","also","am","among","an","and","any","are","as","at","be","because","been","but","by","can","cannot","could","dear","did","do","does","either","else","ever","every","for","from","get","got","had","has","have","he","her","hers","him","his","how","however","i","if","in","into","is","it","its","just","least","let","like","likely","may","me","might","most","must","my","neither","no","nor","not","of","off","often","on","only","or","other","our","own","rather","said","say","says","she","should","since","so","some","than","that","the","their","them","then","there","these","they","this","tis","to","too","twas","us","wants","was","we","were","what","when","where","which","while","who","whom","why","will","with","would","yet","you","your"]),t.Pipeline.registerFunction(t.stopWordFilter,"stopWordFilter"),t.trimmer=function(t){return t.replace(/^\W+/,"").replace(/\W+$/,"")},t.Pipeline.registerFunction(t.trimmer,"trimmer"),t.TokenStore=function(){this.root={docs:{}},this.length=0},t.TokenStore.load=function(t){var e=new this;return e.root=t.root,e.length=t.length,e},t.TokenStore.prototype.add=function(t,e,n){var n=n||this.root,i=t.charAt(0),r=t.slice(1);return i in n||(n[i]={docs:{}}),0===r.length?(n[i].docs[e.ref]=e,void(this.length+=1)):this.add(r,e,n[i])},t.TokenStore.prototype.has=function(t){if(!t)return!1;for(var e=this.root,n=0;n" + title + ""; - container.innerHTML += "

" + getResultBlurb(search_blob[ref].content) + "...


"; - } - } - } else { - container.innerHTML += "
No results found.
"; - } - } - - addContentToIndex(); - window.addEventListener("load", function () { - displaySearchHeading(searchQuery); - displaySearchResults(getRawSearchResults(searchQuery)); - }); - -})(); diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 64b296543..0949a5e5b 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -65,7 +65,7 @@ def add(self, user_item): add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info('Added new user (ID: {0})'.format(user_item.id)) + logger.info('Added new user (ID: {0})'.format(new_user.id)) return new_user # Get workbooks for user From 3a9e0e55577f5961518430d294d66cfd29905daf Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 1 May 2020 10:42:56 -0700 Subject: [PATCH 144/567] Sync development with master branch (#613) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user Co-authored-by: Jac Co-authored-by: Reba Magier From 896a92bddb0f8fdd055f01a36d89d58433f2eaa1 Mon Sep 17 00:00:00 2001 From: Stephen Mitchell Date: Mon, 4 May 2020 13:42:08 -0400 Subject: [PATCH 145/567] Adds hidden_views parameter to publish() (#614) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Adds hidden views parameter to workbook publish * Pycodestyle Co-authored-by: Chris Shin Co-authored-by: Jac Co-authored-by: Reba Magier --- .../server/endpoint/workbooks_endpoint.py | 12 +++++-- tableauserverclient/server/request_factory.py | 34 ++++++++++++++++--- test/test_workbook.py | 23 +++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 1559bc41b..c7c1000bd 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -234,7 +234,11 @@ def delete_permission(self, item, capability_item): @api(version="2.0") @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): + def publish( + self, workbook_item, file_path, mode, + connection_credentials=None, connections=None, as_job=False, + hidden_views=None + ): if connection_credentials is not None: import warnings @@ -277,7 +281,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -287,7 +292,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c filename, file_contents, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) logger.debug('Request xml: {0} '.format(xml_request[:1000])) # Send the publishing request to server diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87529b84f..fbb09fc27 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -38,6 +38,12 @@ def _add_connections_element(connections_element, connection): _add_credentials_element(connection_element, connection_credentials) +def _add_hiddenview_element(views_element, view_name): + view_element = ET.SubElement(views_element, 'view') + view_element.attrib['name'] = view_name + view_element.attrib['hidden'] = "true" + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, 'connectionCredentials') credentials_element.attrib['name'] = connection_credentials.name @@ -448,7 +454,11 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): + def _generate_xml( + self, workbook_item, + connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -467,6 +477,12 @@ def _generate_xml(self, workbook_item, connection_credentials=None, connections= connections_element = ET.SubElement(workbook_element, 'connections') for connection in connections: _add_connections_element(connections_element, connection) + + if hidden_views is not None: + views_element = ET.SubElement(workbook_element, 'views') + for view_name in hidden_views: + _add_hiddenview_element(views_element, view_name) + return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -494,19 +510,27 @@ def update_req(self, workbook_item): return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + def publish_req( + self, workbook_item, filename, file_contents, + connection_credentials=None, connections=None, hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None): + def publish_req_chunked( + self, workbook_item, connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a62f4fc5..f1d9df9e0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -4,6 +4,7 @@ import tableauserverclient as TSC import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory @@ -436,6 +437,28 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_with_hidden_view(self): + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbook, + publish_mode, + hidden_views=['GDP per capita']) + + request_body = m._adapter.request_history[0]._request.body + self.assertIn( + b'', request_body) + def test_publish_async(self): self.server.version = '3.0' baseurl = self.server.workbooks.baseurl From cb00345fd1987bc8ee17367b0adcd69655bdeba9 Mon Sep 17 00:00:00 2001 From: Stephen Mitchell Date: Tue, 12 May 2020 22:10:40 -0400 Subject: [PATCH 146/567] Code cleanup (#618) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Cleans up imports and fixes some errors along the way * pycodestyle fix for single char var name Co-authored-by: Chris Shin Co-authored-by: Jac Co-authored-by: Reba Magier --- tableauserverclient/datetime_helpers.py | 4 +++- tableauserverclient/models/column_item.py | 3 +-- tableauserverclient/models/connection_item.py | 12 ++++++++---- .../models/data_acceleration_report_item.py | 4 ---- tableauserverclient/models/database_item.py | 2 -- tableauserverclient/models/flow_item.py | 2 +- tableauserverclient/models/interval_item.py | 2 +- tableauserverclient/models/job_item.py | 2 -- tableauserverclient/models/pagination_item.py | 6 +++--- .../models/personal_access_token_auth.py | 9 +++++++-- tableauserverclient/models/project_item.py | 4 ++-- tableauserverclient/models/subscription_item.py | 1 - tableauserverclient/models/table_item.py | 5 +---- tableauserverclient/models/task_item.py | 1 - tableauserverclient/models/webhook_item.py | 10 +++------- .../endpoint/data_acceleration_report_endpoint.py | 3 ++- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 7 +++---- .../server/endpoint/default_permissions_endpoint.py | 7 ++++--- tableauserverclient/server/endpoint/endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 6 ++---- .../server/endpoint/groups_endpoint.py | 2 +- tableauserverclient/server/endpoint/jobs_endpoint.py | 1 + .../server/endpoint/metadata_endpoint.py | 1 + .../server/endpoint/permissions_endpoint.py | 7 ++++--- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/schedules_endpoint.py | 2 +- .../server/endpoint/sites_endpoint.py | 3 ++- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 3 +-- .../server/endpoint/tasks_endpoint.py | 1 + .../server/endpoint/users_endpoint.py | 3 ++- .../server/endpoint/views_endpoint.py | 6 +++--- .../server/endpoint/webhooks_endpoint.py | 3 ++- .../server/endpoint/workbooks_endpoint.py | 2 -- tableauserverclient/server/pager.py | 1 - tableauserverclient/server/request_factory.py | 4 +--- tableauserverclient/server/request_options.py | 1 + tableauserverclient/server/server.py | 2 +- 39 files changed, 67 insertions(+), 73 deletions(-) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index d15a3a801..95041f8e1 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,6 +1,8 @@ import datetime -# This code below is from the python documentation for tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html +# This code below is from the python documentation for +# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html + ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 475dd0e2a..9bf198220 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_is_enum, property_not_empty -from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_empty class ColumnItem(object): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 829564839..8f923fecb 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -32,8 +32,10 @@ def connection_type(self): return self._connection_type def __repr__(self): - return ""\ - .format(**self.__dict__) + return ( + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp, ns): @@ -76,11 +78,13 @@ def from_xml_element(cls, parsed_response, ns): connection_item.server_address = connection_xml.get('serverAddress', None) connection_item.server_port = connection_xml.get('serverPort', None) - connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns) + connection_credentials = connection_xml.find( + './/t:connectionCredentials', namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element( + connection_credentials) return all_connection_items diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 2f056d0c4..2b443a3d1 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -21,10 +21,6 @@ def site(self): def sheet_uri(self): return self._sheet_uri - @property - def site(self): - return self._site - @property def unaccelerated_session_count(self): return self._unaccelerated_session_count diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 9aecca6cc..5a7e74737 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission - from .property_decorators import property_is_enum, property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 790000df2..c978d8175 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable from .tag_item import TagItem from ..datetime_helpers import parse_datetime import copy diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 484ee709f..cbc148e88 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -173,7 +173,7 @@ def interval(self, interval_value): try: if not (1 <= int(interval_value) <= 31): raise ValueError(error) - except ValueError as e: + except ValueError: if interval_value != "LastDay": raise ValueError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6ad7f0256..58d1f1396 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime -from .target import Target -from ..datetime_helpers import parse_datetime class JobItem(object): diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 98d6b42f9..a1f5409e3 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -31,10 +31,10 @@ def from_response(cls, resp, ns): return pagination_item @classmethod - def from_single_page_list(cls, l): + def from_single_page_list(cls, single_page_list): item = cls() item._page_number = 1 - item._page_size = len(l) - item._total_available = len(l) + item._page_size = len(single_page_list) + item._total_available = len(single_page_list) return item diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index 875f68c48..13a2391b8 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -8,7 +8,12 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): - return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} + return { + 'personalAccessTokenName': self.token_name, + 'personalAccessTokenSecret': self.personal_access_token + } def __repr__(self): - return "".format(self.token_name, self.personal_access_token) + return "".format( + self.token_name, self.personal_access_token + ) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 15223e695..d6aece83b 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -77,9 +77,9 @@ def name(self, value): def is_default(self): return self.name.lower() == 'default' - def _parse_common_tags(self, project_xml): + def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=NAMESPACE) + project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=ns) if project_xml is not None: (_, name, description, content_permissions, parent_id) = self._parse_element(project_xml) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 5a99fefc2..1a93c60d2 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,5 +1,4 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError from .target import Target diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 8d8f63674..2f00ef2b7 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,9 +1,6 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission -from .column_item import ColumnItem - -from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .property_decorators import property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 780412af9..2f3e6f3aa 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -36,7 +36,6 @@ def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): @classmethod def _parse_element(cls, element, ns): - schedule_id = None schedule_item = None target = None last_run_at = None diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 90fdd4ba2..57bcfeaa4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,10 +1,5 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config -from .tag_item import TagItem -from .view_item import ViewItem -from .permissions_item import PermissionsRule -from ..datetime_helpers import parse_datetime + import re @@ -86,4 +81,5 @@ def _parse_element(webhook_xml, ns): return id, name, url, event, owner_id def __repr__(self): - return "".format(self.id, self.name, self.url, self.event) + return "".format( + self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index b84a38643..fcc2806c6 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -18,7 +18,8 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return "{0}/sites/{1}/dataAccelerationReport".format( + self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index d0fd24c78..85dd406ef 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, DatabaseItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 44dea28df..7a00157fe 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,14 +1,12 @@ 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy @@ -129,7 +127,8 @@ def update(self, datasource_item): server_response = self.put_request(url, update_req) logger.info('Updated datasource item (ID: {0})'.format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) - return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) + return updated_datasource._parse_common_elements( + server_response.content, self.parent_srv.namespace) # Update datasource connections @api(version="2.3") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 0dff025a1..d435a03d6 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -3,7 +3,7 @@ from .. import RequestFactory from ...models import PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -11,13 +11,14 @@ class _DefaultPermissionsEndpoint(Endpoint): - ''' Adds default-permission model to another endpoint + """ Adds default-permission model to another endpoint Tableau default-permissions model applies only to databases and projects and then takes an object type in the uri to set the defaults. This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b2bca229..5e48b5cc2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -74,7 +74,7 @@ def _check_status(self, server_response): # we convert this to a better exception and pass through the raw # response body raise NonXMLResponseError(server_response.content) - except Exception as e: + except Exception: # anything else re-raise here raise diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7bad807e4..44a110e7e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,14 +1,12 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2428ff9be..e0acb4477 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,8 +1,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from ...models.exceptions import UnpopulatedPropertyError from .. import RequestFactory, GroupItem, UserItem, PaginationItem from ..pager import Pager + import logging logger = logging.getLogger('tableau.endpoint.groups') diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index e70c9c313..d8bbe39c7 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase + import logging try: diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 002379407..900b16fb2 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,5 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError + import logging import json diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index b28d6fa17..585fd0052 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -2,7 +2,7 @@ from .. import RequestFactory, PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -10,13 +10,14 @@ class _PermissionsEndpoint(Endpoint): - ''' Adds permission model to another endpoint + """ Adds permission model to another endpoint Tableau permissions model is identical between objects but they are nested under the parent object endpoint (i.e. permissions for workbooks are under /workbooks/:id/permission). This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_PermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f0eb92626..a7f22795c 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, ProjectItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, ProjectItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 06fb7e408..29389c693 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem +from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem import logging import copy from collections import namedtuple diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 6d67fe69e..8a6212a28 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -1,8 +1,9 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SiteItem, PaginationItem -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.sites') diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 70422e208..00a7c6856 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SubscriptionItem, PaginationItem + import logging logger = logging.getLogger('tableau.endpoint.subscriptions') diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b8430a124..032f13016 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,10 +1,9 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint from ..pager import Pager -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, TableItem, ColumnItem, PaginationItem import logging diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index d08209769..a3e5e7b34 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import TaskItem, PaginationItem, RequestFactory + import logging logger = logging.getLogger('tableau.endpoint.tasks') diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0949a5e5b..3ce1f16ab 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,8 +2,9 @@ from .exceptions import MissingRequiredFieldError from .. import RequestFactory, UserItem, WorkbookItem, PaginationItem from ..pager import Pager -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.users') diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 85ae70f93..7c8a4768e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -2,10 +2,10 @@ from .exceptions import MissingRequiredFieldError from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, ViewItem, PaginationItem -from ...models.tag_item import TagItem -import logging +from .. import ViewItem, PaginationItem + from contextlib import closing +import logging logger = logging.getLogger('tableau.endpoint.views') diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 4e69974d1..fe108a27d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,9 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api from ...models import WebhookItem, PaginationItem from .. import RequestFactory import logging + logger = logging.getLogger('tableau.endpoint.webhooks') diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index c7c1000bd..82a5f9cd0 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,11 +1,9 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...models.tag_item import TagItem from ...models.job_item import JobItem from ...filesys_helpers import to_filename, make_download_path diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 75ac8be4e..0e2382fae 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ from functools import partial from . import RequestOptions -from . import Sort class Pager(object): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fbb09fc27..4307e6496 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,11 +1,9 @@ -from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET -from functools import wraps from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule +from ..models import TaskItem def _add_multipart(parts): diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index af2ed27de..4dad4c1ea 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -83,6 +83,7 @@ def apply_query_params(self, url): class _FilterOptionsBase(RequestOptionsBase): """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): self.view_filters = [] diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 42accf722..37371d707 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,7 +3,7 @@ from .exceptions import NotSignedInError from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ + Schedules, ServerInfo, Tasks, Subscriptions, Jobs, Metadata,\ Databases, Tables, Flows, Webhooks, DataAccelerationReport from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError From 1392946b78341bd1165032e600c990447cc9580b Mon Sep 17 00:00:00 2001 From: Mary Brennan Date: Wed, 13 May 2020 13:59:32 -0400 Subject: [PATCH 147/567] update comment to say Python 3.5 is required to run samples (#619) * update comment to say Python 3.5 is required to run samples * pycodestyle fix for single char var name --- samples/create_group.py | 2 +- samples/create_project.py | 2 +- samples/create_schedules.py | 2 +- samples/download_view_image.py | 2 +- samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/login.py | 2 +- samples/move_workbook_projects.py | 2 +- samples/move_workbook_sites.py | 2 +- samples/publish_workbook.py | 2 +- samples/refresh.py | 2 +- samples/refresh_tasks.py | 2 +- samples/set_http_options.py | 2 +- samples/update_connection.py | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/samples/create_group.py b/samples/create_group.py index 3b7892fdf..c6865bc56 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index 744b056d4..ac55da17e 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c8d32b087..c1bcb712f 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py index df2331596..ce6dd3165 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -5,7 +5,7 @@ # For more information, refer to the documentations on 'Query View Image' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 3b585327d..fa0c2318e 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index d247c44dc..91633f38f 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -3,7 +3,7 @@ # to filter and sort on the name of the projects present on site. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 9c5f52a50..9d3c7836a 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index e103eb862..84b3c70d2 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index d3862503d..57a929f6b 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 8bb1b4e50..c31425f25 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 40f0350e5..08bde0ec6 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 2d460abaf..927e9c3ad 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh.py b/samples/refresh.py index 58e3110f3..ba3a2f183 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 214a2131b..f722adb30 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/set_http_options.py b/samples/set_http_options.py index fb5ce2441..9316dfdde 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -2,7 +2,7 @@ # This script demonstrates how to set http options. It will set the option # to not verify SSL certificate, and query all workbooks on site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/update_connection.py b/samples/update_connection.py index 69e4e6377..3449441a4 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse From 1630205f7e84019f855d80b6dbca9837d08ad674 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 21 May 2020 16:10:02 -0700 Subject: [PATCH 148/567] Simple Paging Endpoint for GraphQL/Metadata API (#623) Because GraphQL can be arbitrarily complex and nested, we can't get as smart with an automatic Pager object without parsing the query, and that's a can of worms. So for now, I added a new endpoint that will take a single query with one set of pagination parameters and run through it until it ends. It's not very smart, but it works. --- .../server/endpoint/exceptions.py | 4 + .../server/endpoint/metadata_endpoint.py | 100 ++++++++++++++++-- test/assets/metadata_paged_1.json | 15 +++ test/assets/metadata_paged_2.json | 15 +++ test/assets/metadata_paged_3.json | 15 +++ test/assets/metadata_query_expected_dict.dict | 9 ++ test/test_metadata.py | 31 +++++- 7 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 test/assets/metadata_paged_1.json create mode 100644 test/assets/metadata_paged_2.json create mode 100644 test/assets/metadata_paged_3.json create mode 100644 test/assets/metadata_query_expected_dict.dict diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 757ca5552..3c9226f0f 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -50,6 +50,10 @@ class NonXMLResponseError(Exception): pass +class InvalidGraphQLQuery(Exception): + pass + + class GraphQLError(Exception): def __init__(self, error_payload): self.error = error_payload diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 900b16fb2..9a265a019 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,27 +1,63 @@ from .endpoint import Endpoint, api -from .exceptions import GraphQLError - +from .exceptions import GraphQLError, InvalidGraphQLQuery import logging import json logger = logging.getLogger('tableau.endpoint.metadata') +def is_valid_paged_query(parsed_query): + """Check that the required $first and $afterToken variables are present in the query. + Also check that we are asking for the pageInfo object, so we get the endCursor. There + is no way to do this relilably without writing a GraphQL parser, so simply check that + that the string contains 'hasNextPage' and 'endCursor'""" + return all(k in parsed_query['variables'] for k in ('first', 'afterToken')) and \ + 'hasNextPage' in parsed_query['query'] and \ + 'endCursor' in parsed_query['query'] + + +def extract_values(obj, key): + """Pull all values of specified key from nested JSON. + Taken from: https://hackersandslackers.com/extract-data-from-complex-json-python/""" + arr = [] + + def extract(obj, arr, key): + """Recursively search for values of key in JSON tree.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, (dict, list)): + extract(v, arr, key) + elif k == key: + arr.append(v) + elif isinstance(obj, list): + for item in obj: + extract(item, arr, key) + return arr + + results = extract(obj, arr, key) + return results + + +def get_page_info(result): + next_page = extract_values(result, 'hasNextPage').pop() + cursor = extract_values(result, 'endCursor').pop() + return next_page, cursor + + class Metadata(Endpoint): @property def baseurl(self): return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) - @api("3.2") + @api("3.5") def query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API') url = self.baseurl try: graphql_query = json.dumps({'query': query, 'variables': variables}) - except Exception: - # Place holder for now - raise Exception('Must provide a string') + except Exception as e: + raise InvalidGraphQLQuery('Must provide a string') # Setting content type because post_reuqest defaults to text/xml server_response = self.post_request(url, graphql_query, content_type='text/json') @@ -31,3 +67,55 @@ def query(self, query, variables=None, abort_on_error=False): raise GraphQLError(results['errors']) return results + + @api("3.5") + def paginated_query(self, query, variables=None, abort_on_error=False): + logger.info('Querying Metadata API using a Paged Query') + url = self.baseurl + + if variables is None: + # default paramaters + variables = {'first': 100, 'afterToken': None} + elif (('first' in variables) and ('afterToken' not in variables)): + # they passed a page size but not a token, probably because they're starting at `null` token + variables.update({'afterToken': None}) + + graphql_query = json.dumps({'query': query, 'variables': variables}) + parsed_query = json.loads(graphql_query) + + if not is_valid_paged_query(parsed_query): + raise InvalidGraphQLQuery('Paged queries must have a `$first` and `$afterToken` variables as well as ' + 'a pageInfo object with `endCursor` and `hasNextPage`') + + results_dict = {'pages': []} + paginated_results = results_dict['pages'] + + # get first page + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + + paginated_results.append(results) + + # repeat + has_another_page, cursor = get_page_info(results) + + while has_another_page: + # Update the page + variables.update({'afterToken': cursor}) + # make the call + logger.debug("Calling Token: " + cursor) + graphql_query = json.dumps({'query': query, 'variables': variables}) + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + # verify response + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + # save results and repeat + paginated_results.append(results) + has_another_page, cursor = get_page_info(results) + + logger.info('Sucessfully got all results for paged query') + return results_dict diff --git a/test/assets/metadata_paged_1.json b/test/assets/metadata_paged_1.json new file mode 100644 index 000000000..c1cc0318e --- /dev/null +++ b/test/assets/metadata_paged_1.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==" + }, + "nodes": [ + { + "id": "0039e5d5-25fa-196b-c66e-c0675839e0b0" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_2.json b/test/assets/metadata_paged_2.json new file mode 100644 index 000000000..af9601d59 --- /dev/null +++ b/test/assets/metadata_paged_2.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==" + }, + "nodes": [ + { + "id": "00b191ce-6055-aff5-e275-c26610c8c4d6" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_3.json b/test/assets/metadata_paged_3.json new file mode 100644 index 000000000..958a408ea --- /dev/null +++ b/test/assets/metadata_paged_3.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==" + }, + "nodes": [ + { + "id": "02f3e4d8-856a-da36-f6c5-c900945c57b9" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_query_expected_dict.dict b/test/assets/metadata_query_expected_dict.dict new file mode 100644 index 000000000..241b333d4 --- /dev/null +++ b/test/assets/metadata_query_expected_dict.dict @@ -0,0 +1,9 @@ +{'pages': [{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '0039e5d5-25fa-196b-c66e-c0675839e0b0'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '00b191ce-6055-aff5-e275-c26610c8c4d6'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '02f3e4d8-856a-da36-f6c5-c900945c57b9'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==', + 'hasNextPage': False}}}}]} \ No newline at end of file diff --git a/test/test_metadata.py b/test/test_metadata.py index e2a44734c..1c0846d73 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -10,6 +10,11 @@ METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') +EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict') + +METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json') +METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json') +METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json') EXPECTED_DICT = {'publishedDatasources': [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, @@ -30,7 +35,7 @@ class MetadataTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') self.baseurl = self.server.metadata.baseurl - self.server.version = "3.2" + self.server.version = "3.5" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -46,6 +51,30 @@ def test_metadata_query(self): self.assertDictEqual(EXPECTED_DICT, datasources) + def test_paged_metadata_query(self): + with open(EXPECTED_PAGED_DICT, 'rb') as f: + expected = eval(f.read()) + + # prepare the 3 pages of results + with open(METADATA_PAGE_1, 'rb') as f: + result_1 = f.read().decode() + with open(METADATA_PAGE_2, 'rb') as f: + result_2 = f.read().decode() + with open(METADATA_PAGE_3, 'rb') as f: + result_3 = f.read().decode() + + with requests_mock.mock() as m: + m.post(self.baseurl, [{'text': result_1, 'status_code': 200}, + {'text': result_2, 'status_code': 200}, + {'text': result_3, 'status_code': 200}]) + + # validation checks for endCursor and hasNextPage, + # but the query text doesn't matter for the test + actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage', + variables={'first': 1, 'afterToken': None}) + + self.assertDictEqual(expected, actual) + def test_metadata_query_ignore_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: response_json = json.loads(f.read().decode()) From 7409d3038df9eb93f19c31102a79ee2b4160ac3e Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Thu, 21 May 2020 21:01:30 -0700 Subject: [PATCH 149/567] Support Metadata Services Backfill & Eventing APIs (#626) Simple JSON endpoints that return the status of Metadata Services related events. --- .../server/endpoint/metadata_endpoint.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 9a265a019..ac111d6ef 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -3,6 +3,7 @@ import logging import json + logger = logging.getLogger('tableau.endpoint.metadata') @@ -49,6 +50,10 @@ class Metadata(Endpoint): def baseurl(self): return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + @property + def control_baseurl(self): + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + @api("3.5") def query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API') @@ -68,6 +73,18 @@ def query(self, query, variables=None, abort_on_error=False): return results + @api("3.9") + def backfill_status(self): + url = self.control_baseurl + "/backfill/status" + response = self.get_request(url) + return response.json() + + @api("3.9") + def eventing_status(self): + url = self.control_baseurl + "/eventing/status" + response = self.get_request(url) + return response.json() + @api("3.5") def paginated_query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API using a Paged Query') From ccd5a4f4b1996f832d1772d1b2d86ae2f2e8e808 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 17 Jun 2020 15:46:54 -0700 Subject: [PATCH 150/567] Adds in maxage param to csv and pdf export options (#635) * Adds in maxage param to csv and pdf export options * Fixes style issue * Adding named param to test to be clear --- .../models/property_decorators.py | 2 +- tableauserverclient/server/request_options.py | 45 ++++++++++++++++++- test/test_view.py | 16 ++++--- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 2a47c889a..f1625d112 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -84,7 +84,7 @@ def property_is_int(range, allowed=None): def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid priority defined: {}.".format(value) + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 4dad4c1ea..549b41a28 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,6 @@ +from ..models.property_decorators import property_is_int + + class RequestOptionsBase(object): def apply_query_params(self, url): raise NotImplementedError() @@ -100,8 +103,24 @@ def _append_view_filters(self, params): class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=None): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240)) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] + if self.max_age != 0: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) @@ -116,11 +135,20 @@ def __init__(self, imageresolution=None, maxage=None): self.image_resolution = imageresolution self.max_age = maxage + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240)) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) - if self.max_age: + if self.max_age != 0: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) @@ -148,10 +176,20 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None): + def __init__(self, page_type=None, orientation=None, maxage=0): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240)) + def max_age(self, value): + self._max_age = value def apply_query_params(self, url): params = [] @@ -161,6 +199,9 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + if self.max_age != 0: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) diff --git a/test/test_view.py b/test/test_view.py index 350be83fd..576bc0fca 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -129,14 +129,15 @@ def test_populate_image(self): self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) - def test_populate_image_high_resolution(self): + def test_populate_image_with_options(self): with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high', content=response) + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10', + content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High) + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) self.server.views.populate_image(single_view, req_option) self.assertEqual(response, single_view.image) @@ -144,14 +145,14 @@ def test_populate_pdf(self): with open(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', + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5', content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' size = TSC.PDFRequestOptions.PageType.Letter orientation = TSC.PDFRequestOptions.Orientation.Portrait - req_option = TSC.PDFRequestOptions(size, orientation) + req_option = TSC.PDFRequestOptions(size, orientation, 5) self.server.views.populate_pdf(single_view, req_option) self.assertEqual(response, single_view.pdf) @@ -160,10 +161,11 @@ def test_populate_csv(self): with open(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) + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1', content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - self.server.views.populate_csv(single_view) + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.views.populate_csv(single_view, request_option) csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) From a0b97042a644b84fc52536a4fcf8f134b3aa6f19 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 26 Jun 2020 10:22:17 -0700 Subject: [PATCH 151/567] Adds description to datasource item --- tableauserverclient/models/datasource_item.py | 20 +++++++++++++------ test/assets/datasource_get.xml | 4 ++-- test/assets/datasource_get_by_id.xml | 2 +- test/test_datasource.py | 3 +++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e76a42aae..5e63f4e93 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -12,6 +12,7 @@ def __init__(self, project_id, name=None): self._content_url = None self._created_at = None self._datasource_type = None + self._description = None self._id = None self._initial_tags = set() self._project_name = None @@ -86,6 +87,10 @@ def project_name(self): def datasource_type(self): return self._datasource_type + @property + def description(self): + return self._description + @property def updated_at(self): return self._updated_at @@ -100,13 +105,13 @@ def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): datasource_xml = ET.fromstring(datasource_xml).find('.//t:datasource', namespaces=ns) if datasource_xml is not None: - (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id, + (_, _, _, _, _, _, updated_at, _, project_id, project_name, owner_id, certified, certification_note) = self._parse_element(datasource_xml, ns) - self._set_values(None, None, None, None, None, updated_at, None, project_id, + self._set_values(None, None, None, None, None, None, updated_at, None, project_id, project_name, owner_id, certified, certification_note) return self - def _set_values(self, id, name, datasource_type, content_url, created_at, + def _set_values(self, id, name, datasource_type, description, content_url, created_at, updated_at, tags, project_id, project_name, owner_id, certified, certification_note): if id is not None: self._id = id @@ -114,6 +119,8 @@ def _set_values(self, id, name, datasource_type, content_url, created_at, self.name = name if datasource_type: self._datasource_type = datasource_type + if description: + self._description = description if content_url: self._content_url = content_url if created_at: @@ -140,11 +147,11 @@ def from_response(cls, resp, ns): all_datasource_xml = parsed_response.findall('.//t:datasource', namespaces=ns) for datasource_xml in all_datasource_xml: - (id_, name, datasource_type, content_url, created_at, updated_at, + (id_, name, datasource_type, description, content_url, created_at, updated_at, tags, project_id, project_name, owner_id, certified, certification_note) = cls._parse_element(datasource_xml, ns) datasource_item = cls(project_id) - datasource_item._set_values(id_, name, datasource_type, content_url, created_at, updated_at, + datasource_item._set_values(id_, name, datasource_type, description, content_url, created_at, updated_at, tags, None, project_name, owner_id, certified, certification_note) all_datasource_items.append(datasource_item) return all_datasource_items @@ -154,6 +161,7 @@ def _parse_element(datasource_xml, ns): id_ = datasource_xml.get('id', None) name = datasource_xml.get('name', None) datasource_type = datasource_xml.get('type', None) + description = datasource_xml.get('description', None) content_url = datasource_xml.get('contentUrl', None) created_at = parse_datetime(datasource_xml.get('createdAt', None)) updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) @@ -177,5 +185,5 @@ def _parse_element(datasource_xml, ns): if owner_elem is not None: owner_id = owner_elem.get('id', None) - return (id_, name, datasource_type, content_url, created_at, updated_at, tags, project_id, + return (id_, name, datasource_type, description, content_url, created_at, updated_at, tags, project_id, project_name, owner_id, certified, certification_note) diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml index c3ccfa0da..bb371462a 100644 --- a/test/assets/datasource_get.xml +++ b/test/assets/datasource_get.xml @@ -2,12 +2,12 @@ - + - + diff --git a/test/assets/datasource_get_by_id.xml b/test/assets/datasource_get_by_id.xml index 177899b15..4d7b3ecb8 100644 --- a/test/assets/datasource_get_by_id.xml +++ b/test/assets/datasource_get_by_id.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_datasource.py b/test/test_datasource.py index 2b7cc623c..fc7169f7a 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -40,6 +40,7 @@ def test_get(self): self.assertEqual(2, pagination_item.total_available) self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) self.assertEqual('dataengine', all_datasources[0].datasource_type) + self.assertEqual('SampleDsDescription', all_datasources[0].description) self.assertEqual('SampleDS', all_datasources[0].content_url) self.assertEqual('2016-08-11T21:22:40Z', format_datetime(all_datasources[0].created_at)) self.assertEqual('2016-08-11T21:34:17Z', format_datetime(all_datasources[0].updated_at)) @@ -50,6 +51,7 @@ def test_get(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) + self.assertEqual('description Sample', all_datasources[1].description) self.assertEqual('Sampledatasource', all_datasources[1].content_url) self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].created_at)) self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].updated_at)) @@ -80,6 +82,7 @@ def test_get_by_id(self): self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) self.assertEqual('dataengine', single_datasource.datasource_type) + self.assertEqual('abc description xyz', single_datasource.description) self.assertEqual('Sampledatasource', single_datasource.content_url) self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.created_at)) self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.updated_at)) From eb1835971d070e4d5615a173025b10899ce3e429 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Fri, 26 Jun 2020 12:24:13 -0500 Subject: [PATCH 152/567] User favorites endpoint (#638) * Create FavoriteRequest factory * Create favorites_endpoint * Enable addition of favorites * Enabled deletion of favorites * Fix XML response calls * Genericize descriptor * Fix typo * Remove outdated content * Use more descriptive variable names * Adjust API version * Create Favorite "enum" * Factor response parsing logic to model The favorites item is now a dictionary. The user_item has been altered to reflect this. * Test favorites.get * Test adding a favorite workbook * Test adding favorite view * Test adding favorite data source * Test adding favorite project * Test favorite deletion * Expand favorites test_get * Unpack list of views in class method response * Add Favorites back to import * Replace deprecated assertEquals with assertEqual * Rename Favorite FavoriteItem and encapsulate Co-authored-by: Woods --- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/favorites_item.py | 49 +++++++ tableauserverclient/models/user_item.py | 8 ++ tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/__init__.py | 1 + .../server/endpoint/favorites_endpoint.py | 78 +++++++++++ tableauserverclient/server/request_factory.py | 31 ++++- tableauserverclient/server/server.py | 3 +- test/assets/favorites_add_datasource.xml | 17 +++ test/assets/favorites_add_project.xml | 11 ++ test/assets/favorites_add_view.xml | 14 ++ test/assets/favorites_add_workbook.xml | 20 +++ test/assets/favorites_get.xml | 47 +++++++ test/test_favorites.py | 129 ++++++++++++++++++ test/test_project.py | 12 +- 15 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 tableauserverclient/models/favorites_item.py create mode 100644 tableauserverclient/server/endpoint/favorites_endpoint.py create mode 100644 test/assets/favorites_add_datasource.xml create mode 100644 test/assets/favorites_add_project.xml create mode 100644 test/assets/favorites_add_view.xml create mode 100644 test/assets/favorites_add_workbook.xml create mode 100644 test/assets/favorites_get.xml create mode 100644 test/test_favorites.py diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b5b50fe59..c86057a3d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError +from .favorites_item import FavoriteItem from .group_item import GroupItem from .flow_item import FlowItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py new file mode 100644 index 000000000..7d2408f93 --- /dev/null +++ b/tableauserverclient/models/favorites_item.py @@ -0,0 +1,49 @@ +import xml.etree.ElementTree as ET +import logging +from .workbook_item import WorkbookItem +from .view_item import ViewItem +from .project_item import ProjectItem +from .datasource_item import DatasourceItem + +logger = logging.getLogger('tableau.models.favorites_item') + + +class FavoriteItem: + class Type: + Workbook = 'workbook' + Datasource = 'datasource' + View = 'view' + Project = 'project' + + @classmethod + def from_response(cls, xml, namespace): + favorites = { + 'datasources': [], + 'projects': [], + 'views': [], + 'workbooks': [], + } + + parsed_response = ET.fromstring(xml) + for workbook in parsed_response.findall('.//t:favorite/t:workbook', namespace): + fav_workbook = WorkbookItem('') + fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) + if fav_workbook: + favorites['workbooks'].append(fav_workbook) + for view in parsed_response.findall('.//t:favorite[t:view]', namespace): + fav_views = ViewItem.from_xml_element(view, namespace) + if fav_views: + for fav_view in fav_views: + favorites['views'].append(fav_view) + for datasource in parsed_response.findall('.//t:favorite/t:datasource', namespace): + fav_datasource = DatasourceItem('') + fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) + if fav_datasource: + favorites['datasources'].append(fav_datasource) + for project in parsed_response.findall('.//t:favorite/t:project', namespace): + fav_project = ProjectItem('p') + fav_project._set_values(*fav_project._parse_element(project)) + if fav_project: + favorites['projects'].append(fav_project) + + return favorites diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 3df2004bf..9be38210f 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -42,6 +42,7 @@ def __init__(self, name=None, site_role=None, auth_setting=None): self._id = None self._last_login = None self._workbooks = None + self._favorites = None self.email = None self.fullname = None self.name = name @@ -99,6 +100,13 @@ def workbooks(self): raise UnpopulatedPropertyError(error) return self._workbooks() + @property + def favorites(self): + if self._favorites is None: + error = "User item must be populated with favorites first." + raise UnpopulatedPropertyError(error) + return self._favorites + def to_reference(self): return ResourceReference(id_=self.id, tag_name=self.tag_name) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f382d0dba..aff549559 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -8,7 +8,7 @@ PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ - MissingRequiredFieldError, Flows + MissingRequiredFieldError, Flows, Favorites from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index fce86f98d..1341ecd3f 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -3,6 +3,7 @@ from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint +from .favorites_endpoint import Favorites from .flows_endpoint import Flows from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py new file mode 100644 index 000000000..61536ce42 --- /dev/null +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -0,0 +1,78 @@ +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory +from ...models import FavoriteItem +from ..pager import Pager +import xml.etree.ElementTree as ET +import logging +import copy + +logger = logging.getLogger('tableau.endpoint.favorites') + + +class Favorites(Endpoint): + @property + def baseurl(self): + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Gets all favorites + @api(version="2.5") + def get(self, user_item, req_options=None): + logger.info('Querying all favorites for user {0}'.format(user_item.name)) + url = '{0}/{1}'.format(self.baseurl, user_item.id) + server_response = self.get_request(url, req_options) + + user_item._favorites = FavoriteItem.from_response(server_response.content, + self.parent_srv.namespace) + + @api(version="2.0") + def add_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(workbook_item.name, user_item.id)) + + @api(version="2.0") + def add_favorite_view(self, user_item, view_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(view_item.name, user_item.id)) + + @api(version="2.3") + def add_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(datasource_item.name, user_item.id)) + + @api(version="3.1") + def add_favorite_project(self, user_item, project_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(project_item.name, user_item.id)) + + @api(version="2.0") + def delete_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}/workbooks/{2}'.format(self.baseurl, user_item.id, workbook_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(workbook_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.0") + def delete_favorite_view(self, user_item, view_item): + url = '{0}/{1}/views/{2}'.format(self.baseurl, user_item.id, view_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(view_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.3") + def delete_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}/datasources/{2}'.format(self.baseurl, user_item.id, datasource_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(datasource_item.id, user_item.id)) + self.delete_request(url) + + @api(version="3.1") + def delete_favorite_project(self, user_item, project_item): + url = '{0}/{1}/projects/{2}'.format(self.baseurl, user_item.id, project_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(project_item.id, user_item.id)) + self.delete_request(url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4307e6496..9c869c686 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -3,7 +3,7 @@ from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import TaskItem +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem def _add_multipart(parts): @@ -149,6 +149,34 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) +class FavoriteRequest(object): + def _add_to_req(self, id_, target_type, label): + ''' + + + + ''' + xml_request = ET.Element('tsRequest') + favorite_element = ET.SubElement(xml_request, 'favorite') + target = ET.SubElement(favorite_element, target_type) + favorite_element.attrib['label'] = label + target.attrib['id'] = id_ + + return ET.tostring(xml_request) + + def add_datasource_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) + + def add_project_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Project, name) + + def add_view_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.View, name) + + def add_workbook_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) + + class FileuploadRequest(object): def chunk_req(self, chunk): parts = {'request_payload': ('', '', 'text/xml'), @@ -605,6 +633,7 @@ class RequestFactory(object): Datasource = DatasourceRequest() Database = DatabaseRequest() Empty = EmptyRequest() + Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() Group = GroupRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 37371d707..c36ee0f4b 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, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows, Webhooks, DataAccelerationReport + Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -46,6 +46,7 @@ def __init__(self, server_address, use_server_version=False): self.jobs = Jobs(self) self.workbooks = Workbooks(self) self.datasources = Datasources(self) + self.favorites = Favorites(self) self.flows = Flows(self) self.projects = Projects(self) self.schedules = Schedules(self) diff --git a/test/assets/favorites_add_datasource.xml b/test/assets/favorites_add_datasource.xml new file mode 100644 index 000000000..a1f47ab4f --- /dev/null +++ b/test/assets/favorites_add_datasource.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_project.xml b/test/assets/favorites_add_project.xml new file mode 100644 index 000000000..699e6a4cd --- /dev/null +++ b/test/assets/favorites_add_project.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_view.xml b/test/assets/favorites_add_view.xml new file mode 100644 index 000000000..f6fc15c9a --- /dev/null +++ b/test/assets/favorites_add_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_workbook.xml b/test/assets/favorites_add_workbook.xml new file mode 100644 index 000000000..c8008c9b8 --- /dev/null +++ b/test/assets/favorites_add_workbook.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_get.xml b/test/assets/favorites_get.xml new file mode 100644 index 000000000..3d2e2ee6a --- /dev/null +++ b/test/assets/favorites_get.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_favorites.py b/test/test_favorites.py new file mode 100644 index 000000000..f76517b64 --- /dev/null +++ b/test/test_favorites.py @@ -0,0 +1,129 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_FAVORITES_XML = 'favorites_get.xml' +ADD_FAVORITE_WORKBOOK_XML = 'favorites_add_workbook.xml' +ADD_FAVORITE_VIEW_XML = 'favorites_add_view.xml' +ADD_FAVORITE_DATASOURCE_XML = 'favorites_add_datasource.xml' +ADD_FAVORITE_PROJECT_XML = 'favorites_add_project.xml' + + +class FavoritesTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '2.5' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.favorites.baseurl + self.user = TSC.UserItem('alice', TSC.UserItem.Roles.Viewer) + self.user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + + def test_get(self): + response_xml = read_xml_asset(GET_FAVORITES_XML) + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.get(self.user) + self.assertIsNotNone(self.user._favorites) + self.assertEqual(len(self.user.favorites['workbooks']), 1) + self.assertEqual(len(self.user.favorites['views']), 1) + self.assertEqual(len(self.user.favorites['projects']), 1) + self.assertEqual(len(self.user.favorites['datasources']), 1) + + workbook = self.user.favorites['workbooks'][0] + view = self.user.favorites['views'][0] + datasource = self.user.favorites['datasources'][0] + project = self.user.favorites['projects'][0] + + self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') + self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') + self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') + self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') + + def test_add_favorite_workbook(self): + response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_workbook(self.user, workbook) + + def test_add_favorite_view(self): + response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_view(self.user, view) + + def test_add_favorite_datasource(self): + response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_datasource(self.user, datasource) + + def test_add_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_project(self.user, project) + + def test_delete_favorite_workbook(self): + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.delete('{0}/{1}/workbooks/{2}'.format(self.baseurl, self.user.id, + workbook.id)) + self.server.favorites.delete_favorite_workbook(self.user, workbook) + + def test_delete_favorite_view(self): + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.delete('{0}/{1}/views/{2}'.format(self.baseurl, self.user.id, + view.id)) + self.server.favorites.delete_favorite_view(self.user, view) + + def test_delete_favorite_datasource(self): + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.delete('{0}/{1}/datasources/{2}'.format(self.baseurl, self.user.id, + datasource.id)) + self.server.favorites.delete_favorite_datasource(self.user, datasource) + + def test_delete_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.delete('{0}/{1}/projects/{2}'.format(baseurl, self.user.id, + project.id)) + self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_project.py b/test/test_project.py index b57d52df5..5e9869c6e 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -100,14 +100,14 @@ def test_update_datasource_default_permission(self): new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - self.assertEquals('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + self.assertEqual('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) updated_capabilities = new_rules[0].capabilities - self.assertEquals(4, len(updated_capabilities)) - self.assertEquals('Deny', updated_capabilities['ExportXml']) - self.assertEquals('Allow', updated_capabilities['Read']) - self.assertEquals('Allow', updated_capabilities['Write']) - self.assertEquals('Allow', updated_capabilities['Connect']) + self.assertEqual(4, len(updated_capabilities)) + self.assertEqual('Deny', updated_capabilities['ExportXml']) + self.assertEqual('Allow', updated_capabilities['Read']) + self.assertEqual('Allow', updated_capabilities['Write']) + self.assertEqual('Allow', updated_capabilities['Connect']) def test_update_missing_id(self): single_project = TSC.ProjectItem('test') From 7f76e71c1a4bb3951778ba60ca0c1a2822c720cc Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 26 Jun 2020 10:57:22 -0700 Subject: [PATCH 153/567] Fixing style error --- tableauserverclient/server/endpoint/favorites_endpoint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 61536ce42..b1a90ba00 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -22,8 +22,7 @@ def get(self, user_item, req_options=None): url = '{0}/{1}'.format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - user_item._favorites = FavoriteItem.from_response(server_response.content, - self.parent_srv.namespace) + user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="2.0") def add_favorite_workbook(self, user_item, workbook_item): From d9edc55132d649637065edaf6cd812fe583dc446 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Mon, 29 Jun 2020 15:38:06 -0700 Subject: [PATCH 154/567] Fixes maxage to allow 0 as input (#639) --- tableauserverclient/server/request_options.py | 18 +++++++++--------- test/test_view.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 549b41a28..530d7d1f0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -103,7 +103,7 @@ def _append_view_filters(self, params): class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=None): + def __init__(self, maxage=-1): super(CSVRequestOptions, self).__init__() self.max_age = maxage @@ -112,13 +112,13 @@ def max_age(self): return self._max_age @max_age.setter - @property_is_int(range=(0, 240)) + @property_is_int(range=(0, 240), allowed=[-1]) def max_age(self, value): self._max_age = value def apply_query_params(self, url): params = [] - if self.max_age != 0: + if self.max_age != -1: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) @@ -130,7 +130,7 @@ class ImageRequestOptions(_FilterOptionsBase): class Resolution: High = 'high' - def __init__(self, imageresolution=None, maxage=None): + def __init__(self, imageresolution=None, maxage=-1): super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution self.max_age = maxage @@ -140,7 +140,7 @@ def max_age(self): return self._max_age @max_age.setter - @property_is_int(range=(0, 240)) + @property_is_int(range=(0, 240), allowed=[-1]) def max_age(self, value): self._max_age = value @@ -148,7 +148,7 @@ def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) - if self.max_age != 0: + if self.max_age != -1: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) @@ -176,7 +176,7 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None, maxage=0): + def __init__(self, page_type=None, orientation=None, maxage=-1): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation @@ -187,7 +187,7 @@ def max_age(self): return self._max_age @max_age.setter - @property_is_int(range=(0, 240)) + @property_is_int(range=(0, 240), allowed=[-1]) def max_age(self, value): self._max_age = value @@ -199,7 +199,7 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) - if self.max_age != 0: + if self.max_age != -1: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) diff --git a/test/test_view.py b/test/test_view.py index 576bc0fca..1bd88995a 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -170,6 +170,18 @@ def test_populate_csv(self): csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) + def test_populate_csv_default_maxage(self): + with open(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) + single_view = TSC.ViewItem() + single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + self.server.views.populate_csv(single_view) + + csv_file = b"".join(single_view.csv) + self.assertEqual(response, csv_file) + def test_populate_image_missing_id(self): single_view = TSC.ViewItem() single_view._id = None From 6d48569f21e3627cd86cb158b5e656d9d9923589 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 10 Jul 2020 13:10:56 -0700 Subject: [PATCH 155/567] Adds a sample for publishing datasources (#644) * Adds a sample for publishing datasources * Addresses feedback to use PAT and async flag --- samples/publish_datasource.py | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 samples/publish_datasource.py diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py new file mode 100644 index 000000000..fa0fe2a95 --- /dev/null +++ b/samples/publish_datasource.py @@ -0,0 +1,85 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to publish a datasource to a Tableau server. It will publish +# a specified datasource to the 'default' project of the provided site. +# +# Some optional arguments are provided to demonstrate async publishing, +# as well as providing connection credentials when publishing. If the +# provided datasource file is over 64MB in size, TSC will automatically +# publish the datasource using the chunking method. +# +# For more information, refer to the documentations: +# (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source) +# +# For signing into server, this script uses personal access tokens. For +# more information on personal access tokens, refer to the documentations: +# (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) +# +# To run the script, you must have installed Python 3.5 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Publish a datasource to server.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-i', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--filepath', '-f', required=True, help='filepath to the datasource to publish') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') + parser.add_argument('--conn-username', help='connection username') + parser.add_argument('--conn-password', help='connection password') + parser.add_argument('--conn-embed', help='embed connection password to datasource', action='store_true') + parser.add_argument('--conn-oauth', help='connection is configured to use oAuth', action='store_true') + + args = parser.parse_args() + + # Ensure that both the connection username and password are provided, or none at all + if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): + parser.error("Both the connection username and password must be provided") + + # 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 to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # Create a new datasource item to publish - empty project_id field + # will default the publish to the site's default project + new_datasource = TSC.DatasourceItem(project_id="") + + # Create a connection_credentials item if connection details are provided + new_conn_creds = None + if args.conn_username: + new_conn_creds = TSC.ConnectionCredentials(args.conn_username, args.conn_password, + embed=args.conn_embed, oauth=args.conn_oauth) + + # Define publish mode - Overwrite, Append, or CreateNew + publish_mode = TSC.Server.PublishMode.Overwrite + + # Publish datasource + if args.async_: + # Async publishing, returns a job_item + new_job = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds, as_job=True) + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + else: + # Normal publishing, returns a datasource_item + new_datasource = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds) + print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + + +if __name__ == '__main__': + main() From 6bdd92cb1d1fc261b830baf7a60722c469497a4d Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 10 Jul 2020 13:50:38 -0700 Subject: [PATCH 156/567] Prep for v0.12 (#645) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Prepares v0.12 release * Fixes typo in changelog Co-authored-by: Jac Co-authored-by: Reba Magier --- CHANGELOG.md | 9 +++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbf46748..aa01dac19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.12 (10 July 2020) + +* Added hidden_views parameter to workbook publish method (#614) +* Added simple paging endpoint for GraphQL/Metadata API (#623) +* Added endpoints to Metadata API for retrieving backfill/eventing status (#626) +* Added maxage parameter to CSV and PDF export options (#635) +* Added support for querying, adding, and deleting favorites (#638) +* Added a sample for publishing datasources (#644) + ## 0.11 (1 May 2020) * Added more fields to Data Acceleration config (#588) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cc92fc435..e5c80d4ac 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -36,6 +36,7 @@ The following people have contributed to this project to make it possible, and w * [Geraldine Zanolli](https://github.com/illonage) * [Jordan Woods](https://github.com/jorwoods) * [Reba Magier](https://github.com/rmagier1) +* [Stephen Mitchell](https://github.com/scuml) ## Core Team From 6ef56aa7573cb2c4cfea0032c831d3890f977a1b Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 10 Jul 2020 13:59:46 -0700 Subject: [PATCH 157/567] Development to master for v0.12 (#646) * Sync development with master branch (#613) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user Co-authored-by: Jac Co-authored-by: Reba Magier * Adds hidden_views parameter to publish() (#614) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Adds hidden views parameter to workbook publish * Pycodestyle Co-authored-by: Chris Shin Co-authored-by: Jac Co-authored-by: Reba Magier * Code cleanup (#618) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Cleans up imports and fixes some errors along the way * pycodestyle fix for single char var name Co-authored-by: Chris Shin Co-authored-by: Jac Co-authored-by: Reba Magier * update comment to say Python 3.5 is required to run samples (#619) * update comment to say Python 3.5 is required to run samples * pycodestyle fix for single char var name * Simple Paging Endpoint for GraphQL/Metadata API (#623) Because GraphQL can be arbitrarily complex and nested, we can't get as smart with an automatic Pager object without parsing the query, and that's a can of worms. So for now, I added a new endpoint that will take a single query with one set of pagination parameters and run through it until it ends. It's not very smart, but it works. * Support Metadata Services Backfill & Eventing APIs (#626) Simple JSON endpoints that return the status of Metadata Services related events. * Adds in maxage param to csv and pdf export options (#635) * Adds in maxage param to csv and pdf export options * Fixes style issue * Adding named param to test to be clear * User favorites endpoint (#638) * Create FavoriteRequest factory * Create favorites_endpoint * Enable addition of favorites * Enabled deletion of favorites * Fix XML response calls * Genericize descriptor * Fix typo * Remove outdated content * Use more descriptive variable names * Adjust API version * Create Favorite "enum" * Factor response parsing logic to model The favorites item is now a dictionary. The user_item has been altered to reflect this. * Test favorites.get * Test adding a favorite workbook * Test adding favorite view * Test adding favorite data source * Test adding favorite project * Test favorite deletion * Expand favorites test_get * Unpack list of views in class method response * Add Favorites back to import * Replace deprecated assertEquals with assertEqual * Rename Favorite FavoriteItem and encapsulate Co-authored-by: Woods * Fixing style error * Fixes maxage to allow 0 as input (#639) * Adds a sample for publishing datasources (#644) * Adds a sample for publishing datasources * Addresses feedback to use PAT and async flag * Prep for v0.12 (#645) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Prepares v0.12 release * Fixes typo in changelog Co-authored-by: Jac Co-authored-by: Reba Magier Co-authored-by: Jac Co-authored-by: Reba Magier Co-authored-by: Stephen Mitchell Co-authored-by: Mary Brennan Co-authored-by: Tyler Doyle Co-authored-by: jorwoods Co-authored-by: Woods --- CHANGELOG.md | 9 ++ CONTRIBUTORS.md | 1 + samples/create_group.py | 2 +- samples/create_project.py | 2 +- samples/create_schedules.py | 2 +- samples/download_view_image.py | 2 +- samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/login.py | 2 +- samples/move_workbook_projects.py | 2 +- samples/move_workbook_sites.py | 2 +- samples/publish_datasource.py | 85 ++++++++++++ samples/publish_workbook.py | 2 +- samples/refresh.py | 2 +- samples/refresh_tasks.py | 2 +- samples/set_http_options.py | 2 +- samples/update_connection.py | 2 +- tableauserverclient/datetime_helpers.py | 4 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/column_item.py | 3 +- tableauserverclient/models/connection_item.py | 12 +- .../models/data_acceleration_report_item.py | 4 - tableauserverclient/models/database_item.py | 2 - tableauserverclient/models/favorites_item.py | 49 +++++++ tableauserverclient/models/flow_item.py | 2 +- tableauserverclient/models/interval_item.py | 2 +- tableauserverclient/models/job_item.py | 2 - tableauserverclient/models/pagination_item.py | 6 +- .../models/personal_access_token_auth.py | 9 +- tableauserverclient/models/project_item.py | 4 +- .../models/property_decorators.py | 2 +- .../models/subscription_item.py | 1 - tableauserverclient/models/table_item.py | 5 +- tableauserverclient/models/task_item.py | 1 - tableauserverclient/models/user_item.py | 8 ++ tableauserverclient/models/webhook_item.py | 10 +- tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/__init__.py | 1 + .../data_acceleration_report_endpoint.py | 3 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 7 +- .../endpoint/default_permissions_endpoint.py | 7 +- .../server/endpoint/endpoint.py | 2 +- .../server/endpoint/exceptions.py | 4 + .../server/endpoint/favorites_endpoint.py | 77 +++++++++++ .../server/endpoint/flows_endpoint.py | 6 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 1 + .../server/endpoint/metadata_endpoint.py | 116 +++++++++++++++- .../server/endpoint/permissions_endpoint.py | 7 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/schedules_endpoint.py | 2 +- .../server/endpoint/sites_endpoint.py | 3 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 3 +- .../server/endpoint/tasks_endpoint.py | 1 + .../server/endpoint/users_endpoint.py | 3 +- .../server/endpoint/views_endpoint.py | 6 +- .../server/endpoint/webhooks_endpoint.py | 3 +- .../server/endpoint/workbooks_endpoint.py | 14 +- tableauserverclient/server/pager.py | 1 - tableauserverclient/server/request_factory.py | 67 +++++++-- tableauserverclient/server/request_options.py | 48 ++++++- tableauserverclient/server/server.py | 5 +- test/assets/favorites_add_datasource.xml | 17 +++ test/assets/favorites_add_project.xml | 11 ++ test/assets/favorites_add_view.xml | 14 ++ test/assets/favorites_add_workbook.xml | 20 +++ test/assets/favorites_get.xml | 47 +++++++ test/assets/metadata_paged_1.json | 15 ++ test/assets/metadata_paged_2.json | 15 ++ test/assets/metadata_paged_3.json | 15 ++ test/assets/metadata_query_expected_dict.dict | 9 ++ test/test_favorites.py | 129 ++++++++++++++++++ test/test_metadata.py | 31 ++++- test/test_project.py | 12 +- test/test_view.py | 24 +++- test/test_workbook.py | 23 ++++ 80 files changed, 913 insertions(+), 120 deletions(-) create mode 100644 samples/publish_datasource.py create mode 100644 tableauserverclient/models/favorites_item.py create mode 100644 tableauserverclient/server/endpoint/favorites_endpoint.py create mode 100644 test/assets/favorites_add_datasource.xml create mode 100644 test/assets/favorites_add_project.xml create mode 100644 test/assets/favorites_add_view.xml create mode 100644 test/assets/favorites_add_workbook.xml create mode 100644 test/assets/favorites_get.xml create mode 100644 test/assets/metadata_paged_1.json create mode 100644 test/assets/metadata_paged_2.json create mode 100644 test/assets/metadata_paged_3.json create mode 100644 test/assets/metadata_query_expected_dict.dict create mode 100644 test/test_favorites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbf46748..aa01dac19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.12 (10 July 2020) + +* Added hidden_views parameter to workbook publish method (#614) +* Added simple paging endpoint for GraphQL/Metadata API (#623) +* Added endpoints to Metadata API for retrieving backfill/eventing status (#626) +* Added maxage parameter to CSV and PDF export options (#635) +* Added support for querying, adding, and deleting favorites (#638) +* Added a sample for publishing datasources (#644) + ## 0.11 (1 May 2020) * Added more fields to Data Acceleration config (#588) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index cc92fc435..e5c80d4ac 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -36,6 +36,7 @@ The following people have contributed to this project to make it possible, and w * [Geraldine Zanolli](https://github.com/illonage) * [Jordan Woods](https://github.com/jorwoods) * [Reba Magier](https://github.com/rmagier1) +* [Stephen Mitchell](https://github.com/scuml) ## Core Team diff --git a/samples/create_group.py b/samples/create_group.py index 3b7892fdf..c6865bc56 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index 744b056d4..ac55da17e 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c8d32b087..c1bcb712f 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py index df2331596..ce6dd3165 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -5,7 +5,7 @@ # For more information, refer to the documentations on 'Query View Image' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 3b585327d..fa0c2318e 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index d247c44dc..91633f38f 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -3,7 +3,7 @@ # to filter and sort on the name of the projects present on site. # # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 9c5f52a50..9d3c7836a 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index e103eb862..84b3c70d2 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index d3862503d..57a929f6b 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 2.7.9 or later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 8bb1b4e50..c31425f25 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 40f0350e5..08bde0ec6 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py new file mode 100644 index 000000000..fa0fe2a95 --- /dev/null +++ b/samples/publish_datasource.py @@ -0,0 +1,85 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to publish a datasource to a Tableau server. It will publish +# a specified datasource to the 'default' project of the provided site. +# +# Some optional arguments are provided to demonstrate async publishing, +# as well as providing connection credentials when publishing. If the +# provided datasource file is over 64MB in size, TSC will automatically +# publish the datasource using the chunking method. +# +# For more information, refer to the documentations: +# (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source) +# +# For signing into server, this script uses personal access tokens. For +# more information on personal access tokens, refer to the documentations: +# (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) +# +# To run the script, you must have installed Python 3.5 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Publish a datasource to server.') + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-i', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--filepath', '-f', required=True, help='filepath to the datasource to publish') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') + parser.add_argument('--conn-username', help='connection username') + parser.add_argument('--conn-password', help='connection password') + parser.add_argument('--conn-embed', help='embed connection password to datasource', action='store_true') + parser.add_argument('--conn-oauth', help='connection is configured to use oAuth', action='store_true') + + args = parser.parse_args() + + # Ensure that both the connection username and password are provided, or none at all + if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): + parser.error("Both the connection username and password must be provided") + + # 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 to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # Create a new datasource item to publish - empty project_id field + # will default the publish to the site's default project + new_datasource = TSC.DatasourceItem(project_id="") + + # Create a connection_credentials item if connection details are provided + new_conn_creds = None + if args.conn_username: + new_conn_creds = TSC.ConnectionCredentials(args.conn_username, args.conn_password, + embed=args.conn_embed, oauth=args.conn_oauth) + + # Define publish mode - Overwrite, Append, or CreateNew + publish_mode = TSC.Server.PublishMode.Overwrite + + # Publish datasource + if args.async_: + # Async publishing, returns a job_item + new_job = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds, as_job=True) + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + else: + # Normal publishing, returns a datasource_item + new_datasource = server.datasources.publish(new_datasource, args.filepath, publish_mode, + connection_credentials=new_conn_creds) + print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + + +if __name__ == '__main__': + main() diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 2d460abaf..927e9c3ad 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh.py b/samples/refresh.py index 58e3110f3..ba3a2f183 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 214a2131b..f722adb30 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/set_http_options.py b/samples/set_http_options.py index fb5ce2441..9316dfdde 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -2,7 +2,7 @@ # This script demonstrates how to set http options. It will set the option # to not verify SSL certificate, and query all workbooks on site. # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/samples/update_connection.py b/samples/update_connection.py index 69e4e6377..3449441a4 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 2.7.X or 3.3 and later. +# To run the script, you must have installed Python 3.5 or later. #### import argparse diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index d15a3a801..95041f8e1 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,6 +1,8 @@ import datetime -# This code below is from the python documentation for tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html +# This code below is from the python documentation for +# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html + ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b5b50fe59..c86057a3d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError +from .favorites_item import FavoriteItem from .group_item import GroupItem from .flow_item import FlowItem from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 475dd0e2a..9bf198220 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,7 +1,6 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_is_enum, property_not_empty -from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_empty class ColumnItem(object): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 829564839..8f923fecb 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -32,8 +32,10 @@ def connection_type(self): return self._connection_type def __repr__(self): - return ""\ - .format(**self.__dict__) + return ( + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp, ns): @@ -76,11 +78,13 @@ def from_xml_element(cls, parsed_response, ns): connection_item.server_address = connection_xml.get('serverAddress', None) connection_item.server_port = connection_xml.get('serverPort', None) - connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns) + connection_credentials = connection_xml.find( + './/t:connectionCredentials', namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element( + connection_credentials) return all_connection_items diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 2f056d0c4..2b443a3d1 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -21,10 +21,6 @@ def site(self): def sheet_uri(self): return self._sheet_uri - @property - def site(self): - return self._site - @property def unaccelerated_session_count(self): return self._unaccelerated_session_count diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 9aecca6cc..5a7e74737 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission - from .property_decorators import property_is_enum, property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py new file mode 100644 index 000000000..7d2408f93 --- /dev/null +++ b/tableauserverclient/models/favorites_item.py @@ -0,0 +1,49 @@ +import xml.etree.ElementTree as ET +import logging +from .workbook_item import WorkbookItem +from .view_item import ViewItem +from .project_item import ProjectItem +from .datasource_item import DatasourceItem + +logger = logging.getLogger('tableau.models.favorites_item') + + +class FavoriteItem: + class Type: + Workbook = 'workbook' + Datasource = 'datasource' + View = 'view' + Project = 'project' + + @classmethod + def from_response(cls, xml, namespace): + favorites = { + 'datasources': [], + 'projects': [], + 'views': [], + 'workbooks': [], + } + + parsed_response = ET.fromstring(xml) + for workbook in parsed_response.findall('.//t:favorite/t:workbook', namespace): + fav_workbook = WorkbookItem('') + fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) + if fav_workbook: + favorites['workbooks'].append(fav_workbook) + for view in parsed_response.findall('.//t:favorite[t:view]', namespace): + fav_views = ViewItem.from_xml_element(view, namespace) + if fav_views: + for fav_view in fav_views: + favorites['views'].append(fav_view) + for datasource in parsed_response.findall('.//t:favorite/t:datasource', namespace): + fav_datasource = DatasourceItem('') + fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) + if fav_datasource: + favorites['datasources'].append(fav_datasource) + for project in parsed_response.findall('.//t:favorite/t:project', namespace): + fav_project = ProjectItem('p') + fav_project._set_values(*fav_project._parse_element(project)) + if fav_project: + favorites['projects'].append(fav_project) + + return favorites diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 790000df2..c978d8175 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable from .tag_item import TagItem from ..datetime_helpers import parse_datetime import copy diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 484ee709f..cbc148e88 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -173,7 +173,7 @@ def interval(self, interval_value): try: if not (1 <= int(interval_value) <= 31): raise ValueError(error) - except ValueError as e: + except ValueError: if interval_value != "LastDay": raise ValueError(error) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6ad7f0256..58d1f1396 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,7 +1,5 @@ import xml.etree.ElementTree as ET from ..datetime_helpers import parse_datetime -from .target import Target -from ..datetime_helpers import parse_datetime class JobItem(object): diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 98d6b42f9..a1f5409e3 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -31,10 +31,10 @@ def from_response(cls, resp, ns): return pagination_item @classmethod - def from_single_page_list(cls, l): + def from_single_page_list(cls, single_page_list): item = cls() item._page_number = 1 - item._page_size = len(l) - item._total_available = len(l) + item._page_size = len(single_page_list) + item._total_available = len(single_page_list) return item diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index 875f68c48..13a2391b8 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -8,7 +8,12 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): - return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} + return { + 'personalAccessTokenName': self.token_name, + 'personalAccessTokenSecret': self.personal_access_token + } def __repr__(self): - return "".format(self.token_name, self.personal_access_token) + return "".format( + self.token_name, self.personal_access_token + ) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 15223e695..d6aece83b 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -77,9 +77,9 @@ def name(self, value): def is_default(self): return self.name.lower() == 'default' - def _parse_common_tags(self, project_xml): + def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=NAMESPACE) + project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=ns) if project_xml is not None: (_, name, description, content_permissions, parent_id) = self._parse_element(project_xml) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 2a47c889a..f1625d112 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -84,7 +84,7 @@ def property_is_int(range, allowed=None): def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid priority defined: {}.".format(value) + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 5a99fefc2..1a93c60d2 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,5 +1,4 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError from .target import Target diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 8d8f63674..2f00ef2b7 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,9 +1,6 @@ import xml.etree.ElementTree as ET -from .permissions_item import Permission -from .column_item import ColumnItem - -from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .property_decorators import property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 780412af9..2f3e6f3aa 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -36,7 +36,6 @@ def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): @classmethod def _parse_element(cls, element, ns): - schedule_id = None schedule_item = None target = None last_run_at = None diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 3df2004bf..9be38210f 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -42,6 +42,7 @@ def __init__(self, name=None, site_role=None, auth_setting=None): self._id = None self._last_login = None self._workbooks = None + self._favorites = None self.email = None self.fullname = None self.name = name @@ -99,6 +100,13 @@ def workbooks(self): raise UnpopulatedPropertyError(error) return self._workbooks() + @property + def favorites(self): + if self._favorites is None: + error = "User item must be populated with favorites first." + raise UnpopulatedPropertyError(error) + return self._favorites + def to_reference(self): return ResourceReference(id_=self.id, tag_name=self.tag_name) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 90fdd4ba2..57bcfeaa4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,10 +1,5 @@ import xml.etree.ElementTree as ET -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config -from .tag_item import TagItem -from .view_item import ViewItem -from .permissions_item import PermissionsRule -from ..datetime_helpers import parse_datetime + import re @@ -86,4 +81,5 @@ def _parse_element(webhook_xml, ns): return id, name, url, event, owner_id def __repr__(self): - return "".format(self.id, self.name, self.url, self.event) + return "".format( + self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f382d0dba..aff549559 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -8,7 +8,7 @@ PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ - MissingRequiredFieldError, Flows + MissingRequiredFieldError, Flows, Favorites from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index fce86f98d..1341ecd3f 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -3,6 +3,7 @@ from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint +from .favorites_endpoint import Favorites from .flows_endpoint import Flows from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError from .groups_endpoint import Groups diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index b84a38643..fcc2806c6 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -18,7 +18,8 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return "{0}/sites/{1}/dataAccelerationReport".format( + self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index d0fd24c78..85dd406ef 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, DatabaseItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 44dea28df..7a00157fe 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,14 +1,12 @@ 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy @@ -129,7 +127,8 @@ def update(self, datasource_item): server_response = self.put_request(url, update_req) logger.info('Updated datasource item (ID: {0})'.format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) - return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) + return updated_datasource._parse_common_elements( + server_response.content, self.parent_srv.namespace) # Update datasource connections @api(version="2.3") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 0dff025a1..d435a03d6 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -3,7 +3,7 @@ from .. import RequestFactory from ...models import PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -11,13 +11,14 @@ class _DefaultPermissionsEndpoint(Endpoint): - ''' Adds default-permission model to another endpoint + """ Adds default-permission model to another endpoint Tableau default-permissions model applies only to databases and projects and then takes an object type in the uri to set the defaults. This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b2bca229..5e48b5cc2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -74,7 +74,7 @@ def _check_status(self, server_response): # we convert this to a better exception and pass through the raw # response body raise NonXMLResponseError(server_response.content) - except Exception as e: + except Exception: # anything else re-raise here raise diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 757ca5552..3c9226f0f 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -50,6 +50,10 @@ class NonXMLResponseError(Exception): pass +class InvalidGraphQLQuery(Exception): + pass + + class GraphQLError(Exception): def __init__(self, error_payload): self.error = error_payload diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py new file mode 100644 index 000000000..b1a90ba00 --- /dev/null +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -0,0 +1,77 @@ +from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory +from ...models import FavoriteItem +from ..pager import Pager +import xml.etree.ElementTree as ET +import logging +import copy + +logger = logging.getLogger('tableau.endpoint.favorites') + + +class Favorites(Endpoint): + @property + def baseurl(self): + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Gets all favorites + @api(version="2.5") + def get(self, user_item, req_options=None): + logger.info('Querying all favorites for user {0}'.format(user_item.name)) + url = '{0}/{1}'.format(self.baseurl, user_item.id) + server_response = self.get_request(url, req_options) + + user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="2.0") + def add_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(workbook_item.name, user_item.id)) + + @api(version="2.0") + def add_favorite_view(self, user_item, view_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(view_item.name, user_item.id)) + + @api(version="2.3") + def add_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(datasource_item.name, user_item.id)) + + @api(version="3.1") + def add_favorite_project(self, user_item, project_item): + url = '{0}/{1}'.format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) + server_response = self.put_request(url, add_req) + logger.info('Favorited {0} for user (ID: {1})'.format(project_item.name, user_item.id)) + + @api(version="2.0") + def delete_favorite_workbook(self, user_item, workbook_item): + url = '{0}/{1}/workbooks/{2}'.format(self.baseurl, user_item.id, workbook_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(workbook_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.0") + def delete_favorite_view(self, user_item, view_item): + url = '{0}/{1}/views/{2}'.format(self.baseurl, user_item.id, view_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(view_item.id, user_item.id)) + self.delete_request(url) + + @api(version="2.3") + def delete_favorite_datasource(self, user_item, datasource_item): + url = '{0}/{1}/datasources/{2}'.format(self.baseurl, user_item.id, datasource_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(datasource_item.id, user_item.id)) + self.delete_request(url) + + @api(version="3.1") + def delete_favorite_project(self, user_item, project_item): + url = '{0}/{1}/projects/{2}'.format(self.baseurl, user_item.id, project_item.id) + logger.info('Removing favorite {0} for user (ID: {1})'.format(project_item.id, user_item.id)) + self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7bad807e4..44a110e7e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,14 +1,12 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api 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 from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path -from ...models.tag_item import TagItem from ...models.job_item import JobItem + import os import logging import copy diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2428ff9be..e0acb4477 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,8 +1,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from ...models.exceptions import UnpopulatedPropertyError from .. import RequestFactory, GroupItem, UserItem, PaginationItem from ..pager import Pager + import logging logger = logging.getLogger('tableau.endpoint.groups') diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index e70c9c313..d8bbe39c7 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase + import logging try: diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 002379407..ac111d6ef 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,26 +1,68 @@ from .endpoint import Endpoint, api -from .exceptions import GraphQLError +from .exceptions import GraphQLError, InvalidGraphQLQuery import logging import json + logger = logging.getLogger('tableau.endpoint.metadata') +def is_valid_paged_query(parsed_query): + """Check that the required $first and $afterToken variables are present in the query. + Also check that we are asking for the pageInfo object, so we get the endCursor. There + is no way to do this relilably without writing a GraphQL parser, so simply check that + that the string contains 'hasNextPage' and 'endCursor'""" + return all(k in parsed_query['variables'] for k in ('first', 'afterToken')) and \ + 'hasNextPage' in parsed_query['query'] and \ + 'endCursor' in parsed_query['query'] + + +def extract_values(obj, key): + """Pull all values of specified key from nested JSON. + Taken from: https://hackersandslackers.com/extract-data-from-complex-json-python/""" + arr = [] + + def extract(obj, arr, key): + """Recursively search for values of key in JSON tree.""" + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, (dict, list)): + extract(v, arr, key) + elif k == key: + arr.append(v) + elif isinstance(obj, list): + for item in obj: + extract(item, arr, key) + return arr + + results = extract(obj, arr, key) + return results + + +def get_page_info(result): + next_page = extract_values(result, 'hasNextPage').pop() + cursor = extract_values(result, 'endCursor').pop() + return next_page, cursor + + class Metadata(Endpoint): @property def baseurl(self): return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) - @api("3.2") + @property + def control_baseurl(self): + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + + @api("3.5") def query(self, query, variables=None, abort_on_error=False): logger.info('Querying Metadata API') url = self.baseurl try: graphql_query = json.dumps({'query': query, 'variables': variables}) - except Exception: - # Place holder for now - raise Exception('Must provide a string') + except Exception as e: + raise InvalidGraphQLQuery('Must provide a string') # Setting content type because post_reuqest defaults to text/xml server_response = self.post_request(url, graphql_query, content_type='text/json') @@ -30,3 +72,67 @@ def query(self, query, variables=None, abort_on_error=False): raise GraphQLError(results['errors']) return results + + @api("3.9") + def backfill_status(self): + url = self.control_baseurl + "/backfill/status" + response = self.get_request(url) + return response.json() + + @api("3.9") + def eventing_status(self): + url = self.control_baseurl + "/eventing/status" + response = self.get_request(url) + return response.json() + + @api("3.5") + def paginated_query(self, query, variables=None, abort_on_error=False): + logger.info('Querying Metadata API using a Paged Query') + url = self.baseurl + + if variables is None: + # default paramaters + variables = {'first': 100, 'afterToken': None} + elif (('first' in variables) and ('afterToken' not in variables)): + # they passed a page size but not a token, probably because they're starting at `null` token + variables.update({'afterToken': None}) + + graphql_query = json.dumps({'query': query, 'variables': variables}) + parsed_query = json.loads(graphql_query) + + if not is_valid_paged_query(parsed_query): + raise InvalidGraphQLQuery('Paged queries must have a `$first` and `$afterToken` variables as well as ' + 'a pageInfo object with `endCursor` and `hasNextPage`') + + results_dict = {'pages': []} + paginated_results = results_dict['pages'] + + # get first page + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + + paginated_results.append(results) + + # repeat + has_another_page, cursor = get_page_info(results) + + while has_another_page: + # Update the page + variables.update({'afterToken': cursor}) + # make the call + logger.debug("Calling Token: " + cursor) + graphql_query = json.dumps({'query': query, 'variables': variables}) + server_response = self.post_request(url, graphql_query, content_type='text/json') + results = server_response.json() + # verify response + if abort_on_error and results.get('errors', None): + raise GraphQLError(results['errors']) + # save results and repeat + paginated_results.append(results) + has_another_page, cursor = get_page_info(results) + + logger.info('Sucessfully got all results for paged query') + return results_dict diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index b28d6fa17..585fd0052 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -2,7 +2,7 @@ from .. import RequestFactory, PermissionsRule -from .endpoint import Endpoint, api +from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -10,13 +10,14 @@ class _PermissionsEndpoint(Endpoint): - ''' Adds permission model to another endpoint + """ Adds permission model to another endpoint Tableau permissions model is identical between objects but they are nested under the parent object endpoint (i.e. permissions for workbooks are under /workbooks/:id/permission). This class is meant to be instantated inside a parent endpoint which has these supported endpoints - ''' + """ + def __init__(self, parent_srv, owner_baseurl): super(_PermissionsEndpoint, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f0eb92626..a7f22795c 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, ProjectItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, ProjectItem, PaginationItem, Permission import logging diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 06fb7e408..29389c693 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem +from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem import logging import copy from collections import namedtuple diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 6d67fe69e..8a6212a28 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -1,8 +1,9 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SiteItem, PaginationItem -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.sites') diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 70422e208..00a7c6856 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SubscriptionItem, PaginationItem + import logging logger = logging.getLogger('tableau.endpoint.subscriptions') diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b8430a124..032f13016 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,10 +1,9 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint from ..pager import Pager -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem, PermissionsRule, Permission +from .. import RequestFactory, TableItem, ColumnItem, PaginationItem import logging diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index d08209769..a3e5e7b34 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import TaskItem, PaginationItem, RequestFactory + import logging logger = logging.getLogger('tableau.endpoint.tasks') diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0949a5e5b..3ce1f16ab 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,8 +2,9 @@ from .exceptions import MissingRequiredFieldError from .. import RequestFactory, UserItem, WorkbookItem, PaginationItem from ..pager import Pager -import logging + import copy +import logging logger = logging.getLogger('tableau.endpoint.users') diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 85ae70f93..7c8a4768e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -2,10 +2,10 @@ from .exceptions import MissingRequiredFieldError from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, ViewItem, PaginationItem -from ...models.tag_item import TagItem -import logging +from .. import ViewItem, PaginationItem + from contextlib import closing +import logging logger = logging.getLogger('tableau.endpoint.views') diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 4e69974d1..fe108a27d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,9 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import Endpoint, api from ...models import WebhookItem, PaginationItem from .. import RequestFactory import logging + logger = logging.getLogger('tableau.endpoint.webhooks') diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 1559bc41b..82a5f9cd0 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,11 +1,9 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...models.tag_item import TagItem from ...models.job_item import JobItem from ...filesys_helpers import to_filename, make_download_path @@ -234,7 +232,11 @@ def delete_permission(self, item, capability_item): @api(version="2.0") @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') - def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None, as_job=False): + def publish( + self, workbook_item, file_path, mode, + connection_credentials=None, connections=None, as_job=False, + hidden_views=None + ): if connection_credentials is not None: import warnings @@ -277,7 +279,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) else: logger.info('Publishing {0} to server'.format(filename)) with open(file_path, 'rb') as f: @@ -287,7 +290,8 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None, c filename, file_contents, connection_credentials=conn_creds, - connections=connections) + connections=connections, + hidden_views=hidden_views) logger.debug('Request xml: {0} '.format(xml_request[:1000])) # Send the publishing request to server diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 75ac8be4e..0e2382fae 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ from functools import partial from . import RequestOptions -from . import Sort class Pager(object): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87529b84f..9c869c686 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,11 +1,9 @@ -from ..datetime_helpers import format_datetime import xml.etree.ElementTree as ET -from functools import wraps from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem def _add_multipart(parts): @@ -38,6 +36,12 @@ def _add_connections_element(connections_element, connection): _add_credentials_element(connection_element, connection_credentials) +def _add_hiddenview_element(views_element, view_name): + view_element = ET.SubElement(views_element, 'view') + view_element.attrib['name'] = view_name + view_element.attrib['hidden'] = "true" + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, 'connectionCredentials') credentials_element.attrib['name'] = connection_credentials.name @@ -145,6 +149,34 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) +class FavoriteRequest(object): + def _add_to_req(self, id_, target_type, label): + ''' + + + + ''' + xml_request = ET.Element('tsRequest') + favorite_element = ET.SubElement(xml_request, 'favorite') + target = ET.SubElement(favorite_element, target_type) + favorite_element.attrib['label'] = label + target.attrib['id'] = id_ + + return ET.tostring(xml_request) + + def add_datasource_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) + + def add_project_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Project, name) + + def add_view_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.View, name) + + def add_workbook_req(self, id_, name): + return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) + + class FileuploadRequest(object): def chunk_req(self, chunk): parts = {'request_payload': ('', '', 'text/xml'), @@ -448,7 +480,11 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None, connections=None): + def _generate_xml( + self, workbook_item, + connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = ET.Element('tsRequest') workbook_element = ET.SubElement(xml_request, 'workbook') workbook_element.attrib['name'] = workbook_item.name @@ -467,6 +503,12 @@ def _generate_xml(self, workbook_item, connection_credentials=None, connections= connections_element = ET.SubElement(workbook_element, 'connections') for connection in connections: _add_connections_element(connections_element, connection) + + if hidden_views is not None: + views_element = ET.SubElement(workbook_element, 'views') + for view_name in hidden_views: + _add_hiddenview_element(views_element, view_name) + return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -494,19 +536,27 @@ def update_req(self, workbook_item): return ET.tostring(xml_request) - def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None): + def publish_req( + self, workbook_item, filename, file_contents, + connection_credentials=None, connections=None, hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml'), 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None): + def publish_req_chunked( + self, workbook_item, connection_credentials=None, connections=None, + hidden_views=None + ): xml_request = self._generate_xml(workbook_item, connection_credentials=connection_credentials, - connections=connections) + connections=connections, + hidden_views=hidden_views) parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) @@ -583,6 +633,7 @@ class RequestFactory(object): Datasource = DatasourceRequest() Database = DatabaseRequest() Empty = EmptyRequest() + Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() Group = GroupRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index af2ed27de..530d7d1f0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,6 @@ +from ..models.property_decorators import property_is_int + + class RequestOptionsBase(object): def apply_query_params(self, url): raise NotImplementedError() @@ -83,6 +86,7 @@ def apply_query_params(self, url): class _FilterOptionsBase(RequestOptionsBase): """ Provide a basic implementation of adding view filters to the url """ + def __init__(self): self.view_filters = [] @@ -99,8 +103,24 @@ def _append_view_filters(self, params): class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=-1): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] + if self.max_age != -1: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) @@ -110,16 +130,25 @@ class ImageRequestOptions(_FilterOptionsBase): class Resolution: High = 'high' - def __init__(self, imageresolution=None, maxage=None): + def __init__(self, imageresolution=None, maxage=-1): super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution self.max_age = maxage + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + def apply_query_params(self, url): params = [] if self.image_resolution: params.append('resolution={0}'.format(self.image_resolution)) - if self.max_age: + if self.max_age != -1: params.append('maxAge={0}'.format(self.max_age)) self._append_view_filters(params) @@ -147,10 +176,20 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None): + def __init__(self, page_type=None, orientation=None, maxage=-1): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def apply_query_params(self, url): params = [] @@ -160,6 +199,9 @@ def apply_query_params(self, url): if self.orientation: params.append('orientation={0}'.format(self.orientation)) + if self.max_age != -1: + params.append('maxAge={0}'.format(self.max_age)) + self._append_view_filters(params) return "{0}?{1}".format(url, '&'.join(params)) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 42accf722..c36ee0f4b 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -3,8 +3,8 @@ from .exceptions import NotSignedInError 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, Webhooks, DataAccelerationReport + Schedules, ServerInfo, Tasks, Subscriptions, Jobs, Metadata,\ + Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -46,6 +46,7 @@ def __init__(self, server_address, use_server_version=False): self.jobs = Jobs(self) self.workbooks = Workbooks(self) self.datasources = Datasources(self) + self.favorites = Favorites(self) self.flows = Flows(self) self.projects = Projects(self) self.schedules = Schedules(self) diff --git a/test/assets/favorites_add_datasource.xml b/test/assets/favorites_add_datasource.xml new file mode 100644 index 000000000..a1f47ab4f --- /dev/null +++ b/test/assets/favorites_add_datasource.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_project.xml b/test/assets/favorites_add_project.xml new file mode 100644 index 000000000..699e6a4cd --- /dev/null +++ b/test/assets/favorites_add_project.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_view.xml b/test/assets/favorites_add_view.xml new file mode 100644 index 000000000..f6fc15c9a --- /dev/null +++ b/test/assets/favorites_add_view.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_add_workbook.xml b/test/assets/favorites_add_workbook.xml new file mode 100644 index 000000000..c8008c9b8 --- /dev/null +++ b/test/assets/favorites_add_workbook.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/favorites_get.xml b/test/assets/favorites_get.xml new file mode 100644 index 000000000..3d2e2ee6a --- /dev/null +++ b/test/assets/favorites_get.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metadata_paged_1.json b/test/assets/metadata_paged_1.json new file mode 100644 index 000000000..c1cc0318e --- /dev/null +++ b/test/assets/metadata_paged_1.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==" + }, + "nodes": [ + { + "id": "0039e5d5-25fa-196b-c66e-c0675839e0b0" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_2.json b/test/assets/metadata_paged_2.json new file mode 100644 index 000000000..af9601d59 --- /dev/null +++ b/test/assets/metadata_paged_2.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": true, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==" + }, + "nodes": [ + { + "id": "00b191ce-6055-aff5-e275-c26610c8c4d6" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_paged_3.json b/test/assets/metadata_paged_3.json new file mode 100644 index 000000000..958a408ea --- /dev/null +++ b/test/assets/metadata_paged_3.json @@ -0,0 +1,15 @@ +{ + "data": { + "publishedDatasourcesConnection": { + "pageInfo": { + "hasNextPage": false, + "endCursor": "eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==" + }, + "nodes": [ + { + "id": "02f3e4d8-856a-da36-f6c5-c900945c57b9" + } + ] + } + } +} \ No newline at end of file diff --git a/test/assets/metadata_query_expected_dict.dict b/test/assets/metadata_query_expected_dict.dict new file mode 100644 index 000000000..241b333d4 --- /dev/null +++ b/test/assets/metadata_query_expected_dict.dict @@ -0,0 +1,9 @@ +{'pages': [{'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '0039e5d5-25fa-196b-c66e-c0675839e0b0'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwMzllNWQ1LTI1ZmEtMTk2Yi1jNjZlLWMwNjc1ODM5ZTBiMCJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '00b191ce-6055-aff5-e275-c26610c8c4d6'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAwYjE5MWNlLTYwNTUtYWZmNS1lMjc1LWMyNjYxMGM4YzRkNiJ9fQ==', + 'hasNextPage': True}}}}, + {'data': {'publishedDatasourcesConnection': {'nodes': [{'id': '02f3e4d8-856a-da36-f6c5-c900945c57b9'}], + 'pageInfo': {'endCursor': 'eyJ0eXBlIjoiUHVibGlzaGVkRGF0YXNvdXJjZSIsInNjb3BlIjoic2l0ZXMvMSIsInNvcnRPcmRlclZhbHVlIjp7Imxhc3RJZCI6IjAyZjNlNGQ4LTg1NmEtZGEzNi1mNmM1LWM5MDA5NDVjNTdiOSJ9fQ==', + 'hasNextPage': False}}}}]} \ No newline at end of file diff --git a/test/test_favorites.py b/test/test_favorites.py new file mode 100644 index 000000000..f76517b64 --- /dev/null +++ b/test/test_favorites.py @@ -0,0 +1,129 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_FAVORITES_XML = 'favorites_get.xml' +ADD_FAVORITE_WORKBOOK_XML = 'favorites_add_workbook.xml' +ADD_FAVORITE_VIEW_XML = 'favorites_add_view.xml' +ADD_FAVORITE_DATASOURCE_XML = 'favorites_add_datasource.xml' +ADD_FAVORITE_PROJECT_XML = 'favorites_add_project.xml' + + +class FavoritesTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = '2.5' + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.favorites.baseurl + self.user = TSC.UserItem('alice', TSC.UserItem.Roles.Viewer) + self.user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + + def test_get(self): + response_xml = read_xml_asset(GET_FAVORITES_XML) + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.get(self.user) + self.assertIsNotNone(self.user._favorites) + self.assertEqual(len(self.user.favorites['workbooks']), 1) + self.assertEqual(len(self.user.favorites['views']), 1) + self.assertEqual(len(self.user.favorites['projects']), 1) + self.assertEqual(len(self.user.favorites['datasources']), 1) + + workbook = self.user.favorites['workbooks'][0] + view = self.user.favorites['views'][0] + datasource = self.user.favorites['datasources'][0] + project = self.user.favorites['projects'][0] + + self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') + self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') + self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') + self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') + + def test_add_favorite_workbook(self): + response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_workbook(self.user, workbook) + + def test_add_favorite_view(self): + response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_view(self.user, view) + + def test_add_favorite_datasource(self): + response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(self.baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_datasource(self.user, datasource) + + def test_add_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.put('{0}/{1}'.format(baseurl, self.user.id), + text=response_xml) + self.server.favorites.add_favorite_project(self.user, project) + + def test_delete_favorite_workbook(self): + workbook = TSC.WorkbookItem('') + workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' + workbook.name = 'Superstore' + with requests_mock.mock() as m: + m.delete('{0}/{1}/workbooks/{2}'.format(self.baseurl, self.user.id, + workbook.id)) + self.server.favorites.delete_favorite_workbook(self.user, workbook) + + def test_delete_favorite_view(self): + view = TSC.ViewItem() + view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + view._name = 'ENDANGERED SAFARI' + with requests_mock.mock() as m: + m.delete('{0}/{1}/views/{2}'.format(self.baseurl, self.user.id, + view.id)) + self.server.favorites.delete_favorite_view(self.user, view) + + def test_delete_favorite_datasource(self): + datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' + datasource.name = 'SampleDS' + with requests_mock.mock() as m: + m.delete('{0}/{1}/datasources/{2}'.format(self.baseurl, self.user.id, + datasource.id)) + self.server.favorites.delete_favorite_datasource(self.user, datasource) + + def test_delete_favorite_project(self): + self.server.version = '3.1' + baseurl = self.server.favorites.baseurl + project = TSC.ProjectItem('Tableau') + project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + with requests_mock.mock() as m: + m.delete('{0}/{1}/projects/{2}'.format(baseurl, self.user.id, + project.id)) + self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_metadata.py b/test/test_metadata.py index e2a44734c..1c0846d73 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -10,6 +10,11 @@ METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') +EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict') + +METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json') +METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json') +METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json') EXPECTED_DICT = {'publishedDatasources': [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, @@ -30,7 +35,7 @@ class MetadataTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') self.baseurl = self.server.metadata.baseurl - self.server.version = "3.2" + self.server.version = "3.5" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -46,6 +51,30 @@ def test_metadata_query(self): self.assertDictEqual(EXPECTED_DICT, datasources) + def test_paged_metadata_query(self): + with open(EXPECTED_PAGED_DICT, 'rb') as f: + expected = eval(f.read()) + + # prepare the 3 pages of results + with open(METADATA_PAGE_1, 'rb') as f: + result_1 = f.read().decode() + with open(METADATA_PAGE_2, 'rb') as f: + result_2 = f.read().decode() + with open(METADATA_PAGE_3, 'rb') as f: + result_3 = f.read().decode() + + with requests_mock.mock() as m: + m.post(self.baseurl, [{'text': result_1, 'status_code': 200}, + {'text': result_2, 'status_code': 200}, + {'text': result_3, 'status_code': 200}]) + + # validation checks for endCursor and hasNextPage, + # but the query text doesn't matter for the test + actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage', + variables={'first': 1, 'afterToken': None}) + + self.assertDictEqual(expected, actual) + def test_metadata_query_ignore_error(self): with open(METADATA_QUERY_ERROR, 'rb') as f: response_json = json.loads(f.read().decode()) diff --git a/test/test_project.py b/test/test_project.py index b57d52df5..5e9869c6e 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -100,14 +100,14 @@ def test_update_datasource_default_permission(self): new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - self.assertEquals('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + self.assertEqual('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) updated_capabilities = new_rules[0].capabilities - self.assertEquals(4, len(updated_capabilities)) - self.assertEquals('Deny', updated_capabilities['ExportXml']) - self.assertEquals('Allow', updated_capabilities['Read']) - self.assertEquals('Allow', updated_capabilities['Write']) - self.assertEquals('Allow', updated_capabilities['Connect']) + self.assertEqual(4, len(updated_capabilities)) + self.assertEqual('Deny', updated_capabilities['ExportXml']) + self.assertEqual('Allow', updated_capabilities['Read']) + self.assertEqual('Allow', updated_capabilities['Write']) + self.assertEqual('Allow', updated_capabilities['Connect']) def test_update_missing_id(self): single_project = TSC.ProjectItem('test') diff --git a/test/test_view.py b/test/test_view.py index 350be83fd..1bd88995a 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -129,14 +129,15 @@ def test_populate_image(self): self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) - def test_populate_image_high_resolution(self): + def test_populate_image_with_options(self): with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high', content=response) + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10', + content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High) + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) self.server.views.populate_image(single_view, req_option) self.assertEqual(response, single_view.image) @@ -144,19 +145,32 @@ def test_populate_pdf(self): with open(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', + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5', content=response) single_view = TSC.ViewItem() single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' size = TSC.PDFRequestOptions.PageType.Letter orientation = TSC.PDFRequestOptions.Orientation.Portrait - req_option = TSC.PDFRequestOptions(size, orientation) + req_option = TSC.PDFRequestOptions(size, orientation, 5) self.server.views.populate_pdf(single_view, req_option) self.assertEqual(response, single_view.pdf) def test_populate_csv(self): + with open(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) + single_view = TSC.ViewItem() + single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.views.populate_csv(single_view, request_option) + + csv_file = b"".join(single_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self): with open(POPULATE_CSV, 'rb') as f: response = f.read() with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a62f4fc5..f1d9df9e0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -4,6 +4,7 @@ import tableauserverclient as TSC import xml.etree.ElementTree as ET + from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory @@ -436,6 +437,28 @@ def test_publish(self): self.assertEqual('GDP per capita', new_workbook.views[0].name) self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) + def test_publish_with_hidden_view(self): + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish(new_workbook, + sample_workbook, + publish_mode, + hidden_views=['GDP per capita']) + + request_body = m._adapter.request_history[0]._request.body + self.assertIn( + b'', request_body) + def test_publish_async(self): self.server.version = '3.0' baseurl = self.server.workbooks.baseurl From 4cbd8007f64dbc93e1bf89981f5cfbcec01f83bb Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 21 Jul 2020 10:16:01 -0700 Subject: [PATCH 158/567] Fixes login sample to pass in sitename for username/password auth --- samples/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/login.py b/samples/login.py index 57a929f6b..339a711cb 100644 --- a/samples/login.py +++ b/samples/login.py @@ -36,7 +36,7 @@ def main(): if args.username: # Trying to authenticate using username and password. password = getpass.getpass("Password: ") - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.sitename) with server.auth.sign_in(tableau_auth): print('Logged in successfully') From f972ea624cf4679261fa2f3a1ab7202903d46b14 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 21 Jul 2020 17:18:21 -0500 Subject: [PATCH 159/567] Create notes property from XML response (#571) Create notes property from XML response Co-authored-by: Jordan Woods --- tableauserverclient/models/job_item.py | 12 ++++++++++-- test/assets/job_get_by_id.xml | 14 ++++++++++++++ test/test_job.py | 20 +++++++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 test/assets/job_get_by_id.xml diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 58d1f1396..cc8b7df43 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -3,7 +3,8 @@ class JobItem(object): - def __init__(self, id_, job_type, progress, created_at, started_at=None, completed_at=None, finish_code=0): + def __init__(self, id_, job_type, progress, created_at, started_at=None, + completed_at=None, finish_code=0, notes=None): self._id = id_ self._type = job_type self._progress = progress @@ -11,6 +12,7 @@ def __init__(self, id_, job_type, progress, created_at, started_at=None, complet self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code + self._notes = notes or [] @property def id(self): @@ -40,6 +42,10 @@ def completed_at(self): def finish_code(self): return self._finish_code + @property + def notes(self): + return self._notes + def __repr__(self): return "".format(**self.__dict__) @@ -63,7 +69,9 @@ def _parse_element(cls, element, ns): started_at = parse_datetime(element.get('startedAt', None)) completed_at = parse_datetime(element.get('completedAt', None)) finish_code = element.get('finishCode', -1) - return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code) + notes = [note.text for note in + element.findall('.//t:notes', namespaces=ns)] or None + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes) class BackgroundJobItem(object): diff --git a/test/assets/job_get_by_id.xml b/test/assets/job_get_by_id.xml new file mode 100644 index 000000000..b142dfe2f --- /dev/null +++ b/test/assets/job_get_by_id.xml @@ -0,0 +1,14 @@ + + + + + Job detail notes + + + More detail + + + diff --git a/test/test_job.py b/test/test_job.py index ee80450ca..08b98b815 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -4,10 +4,12 @@ import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc +from ._utils import read_xml_asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') -GET_XML = os.path.join(TEST_ASSET_DIR, 'job_get.xml') +GET_XML = 'job_get.xml' +GET_BY_ID_XML = 'job_get_by_id.xml' class JobTests(unittest.TestCase): @@ -22,8 +24,7 @@ def setUp(self): self.baseurl = self.server.jobs.baseurl def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_jobs, pagination_item = self.server.jobs.get() @@ -41,6 +42,19 @@ def test_get(self): self.assertEqual(started_at, job.started_at) self.assertEqual(ended_at, job.ended_at) + def test_get_by_id(self): + response_xml = read_xml_asset(GET_BY_ID_XML) + job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + job = self.server.jobs.get_by_id(job_id) + + created_at = datetime(2020, 5, 13, 20, 23, 45, tzinfo=utc) + updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) + ended_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ['Job detail notes']) + def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) From ccbbc49b5278d6c4605262612cb18ebd265aea0a Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 22 Jul 2020 14:09:10 -0700 Subject: [PATCH 160/567] Fixes default sitename in login sample and adds more print statements (#652) --- samples/login.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/samples/login.py b/samples/login.py index 339a711cb..29e02e14e 100644 --- a/samples/login.py +++ b/samples/login.py @@ -22,7 +22,7 @@ def main(): group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--username', '-u', help='username to sign into the server') group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') - parser.add_argument('--sitename', '-S', default=None) + parser.add_argument('--sitename', '-S', default='') args = parser.parse_args() @@ -36,13 +36,18 @@ def main(): if args.username: # Trying to authenticate using username and password. password = getpass.getpass("Password: ") + + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.sitename, args.username)) tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.sitename) with server.auth.sign_in(tableau_auth): print('Logged in successfully') else: # Trying to authenticate using personal access tokens. - personal_access_token = input("Personal Access Token: ") + personal_access_token = getpass.getpass("Personal Access Token: ") + + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}" + .format(args.server, args.sitename, args.token_name)) tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, personal_access_token=personal_access_token, site_id=args.sitename) with server.auth.sign_in_with_personal_access_token(tableau_auth): From 0da852e8abd24d279c37ddd225c0044658db7930 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 21 Jul 2020 10:16:01 -0700 Subject: [PATCH 161/567] Fixes login sample to pass in sitename for username/password auth (cherry picked from commit 4cbd8007f64dbc93e1bf89981f5cfbcec01f83bb) --- samples/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/login.py b/samples/login.py index 57a929f6b..339a711cb 100644 --- a/samples/login.py +++ b/samples/login.py @@ -36,7 +36,7 @@ def main(): if args.username: # Trying to authenticate using username and password. password = getpass.getpass("Password: ") - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.sitename) with server.auth.sign_in(tableau_auth): print('Logged in successfully') From 3041b4aecaf5dac3a4af3cb8797f114f3063fa5b Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 22 Jul 2020 14:09:10 -0700 Subject: [PATCH 162/567] Fixes default sitename in login sample and adds more print statements (#652) (cherry picked from commit ccbbc49b5278d6c4605262612cb18ebd265aea0a) --- samples/login.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/samples/login.py b/samples/login.py index 339a711cb..29e02e14e 100644 --- a/samples/login.py +++ b/samples/login.py @@ -22,7 +22,7 @@ def main(): group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--username', '-u', help='username to sign into the server') group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') - parser.add_argument('--sitename', '-S', default=None) + parser.add_argument('--sitename', '-S', default='') args = parser.parse_args() @@ -36,13 +36,18 @@ def main(): if args.username: # Trying to authenticate using username and password. password = getpass.getpass("Password: ") + + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.sitename, args.username)) tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.sitename) with server.auth.sign_in(tableau_auth): print('Logged in successfully') else: # Trying to authenticate using personal access tokens. - personal_access_token = input("Personal Access Token: ") + personal_access_token = getpass.getpass("Personal Access Token: ") + + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}" + .format(args.server, args.sitename, args.token_name)) tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, personal_access_token=personal_access_token, site_id=args.sitename) with server.auth.sign_in_with_personal_access_token(tableau_auth): From d68244b0a43a97f7f36841d573e3354b46ebd99f Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 22 Jul 2020 15:23:10 -0700 Subject: [PATCH 163/567] Updates changelog with patch notes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa01dac19..d0da3f294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.12.1 (22 July 2020) + +* Fixed login.py sample to properly handle sitename (#652) + ## 0.12 (10 July 2020) * Added hidden_views parameter to workbook publish method (#614) From 803d25714da5da444daac76d539f90c7e775b5c0 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 4 Aug 2020 18:21:29 -0700 Subject: [PATCH 164/567] Minor edits and cleanup --- README.md | 6 +++--- contributing.md | 27 ++++++++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 51e23549a..e2c30704a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Tableau Server Client (Python) -[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) + +[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://travis-ci.org/tableau/server-client-python.svg?branch=master)](https://travis-ci.org/tableau/server-client-python) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: @@ -7,8 +8,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code and sample files. +This repository contains Python source code and sample files. Python versions 3.5 and up are supported. For more information on installing and using TSC, see the documentation: - diff --git a/contributing.md b/contributing.md index 4c7cdef00..c7f487ec3 100644 --- a/contributing.md +++ b/contributing.md @@ -15,7 +15,7 @@ a feature do not require the CLA. ## Issues and Feature Requests -To submit an issue/bug report, or to request a feature, please submit a [github issue](https://github.com/tableau/server-client-python/issues) to the repo. +To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** @@ -48,19 +48,24 @@ anyone can add to an issue: ## Fixes, Implementations, and Documentation For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide) +creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide). If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle somewhere. - ## Getting Started -> pip install versioneer -> python setup.py build -> python setup.py test -> - -### before committing -Our CI runs include a python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin -> pycodestyle tableauserverclient test samples + +```shell +pip install versioneer +python setup.py build +python setup.py test +``` + +### Before Committing + +Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. + +```shell +pycodestyle tableauserverclient test samples +``` From 60539c5295cccded94f69bf1539f2f07b58d3554 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 4 Aug 2020 18:22:29 -0700 Subject: [PATCH 165/567] Rename a duplicate test method so they all run --- test/test_task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_task.py b/test/test_task.py index cf7879305..789f97187 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -105,7 +105,7 @@ def test_get_materializeviews_tasks(self): self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) - def test_delete(self): + def test_delete_data_acceleration(self): with requests_mock.mock() as m: m.delete('{}/{}/{}'.format( self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, From 7803a6ea275b782913f9c314f3b1ce85f0b93077 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 4 Aug 2020 18:23:38 -0700 Subject: [PATCH 166/567] Cleanup test comments and descriptions, no functional changes --- samples/add_default_permission.py | 2 +- samples/create_group.py | 4 ++-- samples/create_project.py | 2 +- samples/download_view_image.py | 2 +- samples/export.py | 9 ++++++++- samples/export_wb.py | 7 +++++-- samples/filter_sort_groups.py | 4 ++-- samples/filter_sort_projects.py | 3 +-- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/pagination_sample.py | 2 +- samples/query_permissions.py | 2 +- samples/refresh.py | 2 +- samples/set_refresh_schedule.py | 10 +++++++++- 14 files changed, 35 insertions(+), 18 deletions(-) diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index b6dbdd479..63c38f53d 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Add workbook default permission for a given project') + parser = argparse.ArgumentParser(description='Add workbook default permissions for a given project.') parser.add_argument('--server', '-s', required=True, help='Server address') parser.add_argument('--username', '-u', required=True, help='Username to sign into server') parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') diff --git a/samples/create_group.py b/samples/create_group.py index c6865bc56..7f9dc1e96 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to create groups using the Tableau +# This script demonstrates how to create a group using the Tableau # Server Client. # # To run the script, you must have installed Python 3.5 or later. @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + parser = argparse.ArgumentParser(description='Creates a sample user group.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/create_project.py b/samples/create_project.py index ac55da17e..0380cb8a0 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -26,7 +26,7 @@ def create_project(server, project_item): def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Create new projects.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) diff --git a/samples/download_view_image.py b/samples/download_view_image.py index ce6dd3165..07162eebf 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -17,7 +17,7 @@ def main(): - parser = argparse.ArgumentParser(description='Query View Image From Server') + parser = argparse.ArgumentParser(description='Download image of a specified view.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--site-id', '-si', required=False, help='content url for site the view is on') diff --git a/samples/export.py b/samples/export.py index 67b3319a8..b8cd01140 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,3 +1,10 @@ +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.5 or later. +#### + import argparse import getpass import logging @@ -6,7 +13,7 @@ def main(): - parser = argparse.ArgumentParser(description='Export a view as an image, pdf, or csv') + parser = argparse.ArgumentParser(description='Export a view as an image, PDF, or CSV') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) diff --git a/samples/export_wb.py b/samples/export_wb.py index 8d3640ab4..334d57c89 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -1,9 +1,12 @@ -# +#### # This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a # workbook. # # You will need to do `pip install PyPDF2` to use this sample. # +# To run the script, you must have installed Python 3.5 or later. +#### + import argparse import getpass @@ -48,7 +51,7 @@ def cleanup(tempdir): def main(): - parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook') + parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.') 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') diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index fa0c2318e..f8123a29c 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -1,5 +1,5 @@ #### -# This script demonstrates how to filter groups using the Tableau +# This script demonstrates how to filter and sort groups using the Tableau # Server Client. # # To run the script, you must have installed Python 3.5 or later. @@ -24,7 +24,7 @@ def create_example_group(group_name='Example Group', server=None): def main(): - parser = argparse.ArgumentParser(description='Filter on groups') + parser = argparse.ArgumentParser(description='Filter and sort groups.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 91633f38f..0c62614b0 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -2,7 +2,6 @@ # This script demonstrates how to use the Tableau Server Client # to filter and sort on the name of the projects present on site. # -# # To run the script, you must have installed Python 3.5 or later. #### @@ -26,7 +25,7 @@ def create_example_project(name='Example Project', content_permissions='LockedTo def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Filter and sort projects.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 9d3c7836a..1aeb7298e 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Cancel all of the running background jobs') + parser = argparse.ArgumentParser(description='Cancel all of the running background jobs.') 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') diff --git a/samples/list.py b/samples/list.py index 84b3c70d2..10e11ac04 100644 --- a/samples/list.py +++ b/samples/list.py @@ -14,7 +14,7 @@ def main(): - parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') + 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="", help='site to log into, do not specify for default site') parser.add_argument('--token-name', '-n', required=True, help='username to signin under') diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 25effd7b2..6779023ba 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -19,7 +19,7 @@ def main(): - parser = argparse.ArgumentParser(description='Return a list of all of the workbooks on your server') + parser = argparse.ArgumentParser(description='Demonstrate pagination on the list of workbooks on the server.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 48120f398..a253adc9a 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -14,7 +14,7 @@ def main(): - parser = argparse.ArgumentParser(description='Query permissions of a given resource') + parser = argparse.ArgumentParser(description='Query permissions of a given resource.') parser.add_argument('--server', '-s', required=True, help='Server address') parser.add_argument('--username', '-u', required=True, help='Username to sign into server') parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') diff --git a/samples/refresh.py b/samples/refresh.py index ba3a2f183..96937a6e3 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -12,7 +12,7 @@ def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description='Trigger a refresh task on a workbook or datasource.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--site', '-S', default=None) diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index edb94f47e..2d4761560 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -1,3 +1,11 @@ +#### +# This script demonstrates how to set the refresh schedule for +# a workbook or datasource. +# +# To run the script, you must have installed Python 3.5 or later. +#### + + import argparse import getpass import logging @@ -6,7 +14,7 @@ def usage(args): - parser = argparse.ArgumentParser(description='Explore workbook functions supported by the Server API.') + parser = argparse.ArgumentParser(description='Set refresh schedule for a workbook or datasource.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', From 12aacb9c7e1bac0a9c76b1e01ef3bb55548e77fa Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 4 Aug 2020 21:32:39 -0700 Subject: [PATCH 167/567] Add support for Python 3.8 Only test_publish_with_hidden_view() needed changes because the order of attributes in the XML request body were swapped for some reason in 3.8 compared to prior Pythons. --- .travis.yml | 1 + test/test_workbook.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 68cee02ad..41316d700 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,7 @@ python: - "3.5" - "3.6" - "3.7" + - "3.8" # command to install dependencies install: - "pip install -e ." diff --git a/test/test_workbook.py b/test/test_workbook.py index f1d9df9e0..6f0e7f18a 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,5 +1,6 @@ import unittest import os +import re import requests_mock import tableauserverclient as TSC import xml.etree.ElementTree as ET @@ -456,8 +457,8 @@ def test_publish_with_hidden_view(self): hidden_views=['GDP per capita']) request_body = m._adapter.request_history[0]._request.body - self.assertIn( - b'', request_body) + self.assertTrue(re.search(rb'<\/views>', request_body)) + self.assertTrue(re.search(rb'<\/views>', request_body)) def test_publish_async(self): self.server.version = '3.0' From 4a14d396c7f736c95763d5e1facc96f77d351361 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 11 Aug 2020 11:32:07 -0500 Subject: [PATCH 168/567] Implement server.users.populate_favorites (#656) --- .../server/endpoint/users_endpoint.py | 2 +- test/test_user.py | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3ce1f16ab..868287493 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -90,4 +90,4 @@ def _get_wbs_for_user(self, user_item, req_options=None): return workbook_item, pagination_item def populate_favorites(self, user_item): - raise NotImplementedError('REST API currently does not support the ability to query favorites') + self.parent_srv.favorites.get(user_item) diff --git a/test/test_user.py b/test/test_user.py index 8df2f2b2e..6eb6ad223 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -12,7 +12,7 @@ UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'user_update.xml') ADD_XML = os.path.join(TEST_ASSET_DIR, 'user_add.xml') POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_workbooks.xml') -ADD_FAVORITE_XML = os.path.join(TEST_ASSET_DIR, 'user_add_favorite.xml') +GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, 'favorites_get.xml') class UserTests(unittest.TestCase): @@ -146,3 +146,28 @@ def test_populate_workbooks(self): def test_populate_workbooks_missing_id(self): single_user = TSC.UserItem('test', 'Interactor') self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) + + def test_populate_favorites(self): + self.server.version = '2.5' + baseurl = self.server.favorites.baseurl + single_user = TSC.UserItem('test', 'Interactor') + with open(GET_FAVORITES_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get('{0}/{1}'.format(baseurl, single_user.id), text=response_xml) + self.server.users.populate_favorites(single_user) + self.assertIsNotNone(single_user._favorites) + self.assertEqual(len(single_user.favorites['workbooks']), 1) + self.assertEqual(len(single_user.favorites['views']), 1) + self.assertEqual(len(single_user.favorites['projects']), 1) + self.assertEqual(len(single_user.favorites['datasources']), 1) + + workbook = single_user.favorites['workbooks'][0] + view = single_user.favorites['views'][0] + datasource = single_user.favorites['datasources'][0] + project = single_user.favorites['projects'][0] + + self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') + self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') + self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') + self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') From ce078cbc9724eb38b7018f646c590b9b9dbdc44d Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 11 Aug 2020 11:32:46 -0500 Subject: [PATCH 169/567] Enable switch site functionality (#655) * Add switch_site functionality * Add test for site switch * Ignore same site error Co-authored-by: Jordan Woods --- .../server/endpoint/auth_endpoint.py | 23 ++++++++++++++++++- tableauserverclient/server/request_factory.py | 7 ++++++ test/test_auth.py | 16 +++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 10f4cb4db..f74b88b21 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,5 +1,5 @@ from ..request_factory import RequestFactory - +from .exceptions import ServerResponseError from .endpoint import Endpoint, api import xml.etree.ElementTree as ET import logging @@ -52,3 +52,24 @@ def sign_out(self): self.post_request(url, '') self.parent_srv._clear_auth() logger.info('Signed out') + + @api(version="2.6") + def switch_site(self, site_item): + url = "{0}/{1}".format(self.baseurl, 'switchSite') + switch_req = RequestFactory.Auth.switch_req(site_item.content_url) + try: + server_response = self.post_request(url, switch_req) + except ServerResponseError as e: + if e.code == "403070": + return Auth.contextmgr(self.sign_out) + else: + raise e + self.parent_srv._namespace.detect(server_response.content) + self._check_status(server_response) + parsed_response = ET.fromstring(server_response.content) + site_id = parsed_response.find('.//t:site', namespaces=self.parent_srv.namespace).get('id', None) + user_id = parsed_response.find('.//t:user', namespaces=self.parent_srv.namespace).get('id', None) + auth_token = parsed_response.find('t:credentials', namespaces=self.parent_srv.namespace).get('token', None) + self.parent_srv._set_auth(site_id, user_id, auth_token) + logger.info('Signed into {0} as user with id {1}'.format(self.parent_srv.server_address, user_id)) + return Auth.contextmgr(self.sign_out) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 9c869c686..c1a54760a 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -67,6 +67,13 @@ def signin_req(self, auth_item): user_element.attrib['id'] = auth_item.user_id_to_impersonate return ET.tostring(xml_request) + def switch_req(self, site_content_url): + xml_request = ET.Element('tsRequest') + + site_element = ET.SubElement(xml_request, 'site') + site_element.attrib['contentUrl'] = site_content_url + return ET.tostring(xml_request) + class ColumnRequest(object): def update_req(self, column_item): diff --git a/test/test_auth.py b/test/test_auth.py index 28e241335..b879ab121 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -90,3 +90,19 @@ def test_sign_out(self): self.assertIsNone(self.server._auth_token) self.assertIsNone(self.server._site_id) self.assertIsNone(self.server._user_id) + + def test_switch_site(self): + self.server.version = '2.6' + baseurl = self.server.auth.baseurl + site_id, user_id, auth_token = list('123') + self.server._set_auth(site_id, user_id, auth_token) + with open(SIGN_IN_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(baseurl + '/switchSite', text=response_xml) + site = TSC.SiteItem('Samples', 'Samples') + self.server.auth.switch_site(site) + + self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) + self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) + self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) From d666f08e72ee55d93d98b8eb71b8d7124ec48c1d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 13 Aug 2020 08:30:12 -0500 Subject: [PATCH 170/567] Create Data Alert capabilities --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/data_alert_item.py | 190 ++++++++++++++++++ tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 1 + .../server/endpoint/data_alert_endpoint.py | 87 ++++++++ tableauserverclient/server/request_factory.py | 23 +++ tableauserverclient/server/server.py | 3 +- 8 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 tableauserverclient/models/data_alert_item.py create mode 100644 tableauserverclient/server/endpoint/data_alert_endpoint.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bb938c8fa..b438d8a2e 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,5 +1,5 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ConnectionCredentials, ConnectionItem, DatasourceItem,\ +from .models import ConnectionCredentials, ConnectionItem, DataAlertItem, DatasourceItem,\ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\ diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index c86057a3d..dff12a29d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -2,6 +2,7 @@ from .connection_item import ConnectionItem from .column_item import ColumnItem from .data_acceleration_report_item import DataAccelerationReportItem +from .data_alert_item import DataAlertItem from .datasource_item import DatasourceItem from .database_item import DatabaseItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py new file mode 100644 index 000000000..fbce4077c --- /dev/null +++ b/tableauserverclient/models/data_alert_item.py @@ -0,0 +1,190 @@ +import xml.etree.ElementTree as ET + +from .property_decorators import property_not_empty +from .user_item import UserItem +from .view_item import ViewItem + + +class DataAlertItem(object): + def __init__(self): + self._id = None + self._subject = None + self._creatorId = None + self._createdAt = None + self._updatedAt = None + self._frequency = None + self._public = None + self._owner_id = None + self._owner_name = None + self._view_id = None + self._view_name = None + self._workbook_id = None + self._workbook_name = None + self._project_id = None + self._project_name = None + self._recipients = None + + def __repr__(self): + return "".format(self.id, self.subject, + self.frequency, self.public) + + @property + def id(self): + return self._id + + @property + def subject(self): + return self._subject + + @subject.setter + @property_not_empty + def subject(self, value): + self._subject = value + + @property + def frequency(self): + return self._frequency + + @frequency.setter + @property_not_empty + def frequency(self, value): + self._frequency = value + + @property + def public(self): + return self._public + + @public.setter + @property_not_empty + def public(self, value): + self._public = value + + @property + def creatorId(self): + return self._creatorId + + @property + def recipients(self): + return self._recipients or list() + + @property + def createdAt(self): + return self._createdAt + + @property + def updatedAt(self): + return self._updatedAt + + @property + def owner_id(self): + return self._owner_id + + @property + def owner_name(self): + return self._owner_name + + @property + def view_id(self): + return self._view_id + + @property + def view_name(self): + return self._view_name + + @property + def workbook_id(self): + return self._workbook_id + + @property + def workbook_name(self): + return self._workbook_name + + @property + def project_id(self): + return self._project_id + + @property + def project_name(self): + return self._project_name + + def _set_values(self, id, subject, creatorId, createdAt, updatedAt, + frequency, public, recipients, owner_id, owner_name, + view_id, view_name, workbook_id, workbook_name, project_id, + project_name): + if id is not None: + self._id = id + if subject: + self._subject = subject + if creatorId: + self._creatorId = creatorId + if createdAt: + self._createdAt = createdAt + if updatedAt: + self._updatedAt = updatedAt + if frequency: + self._frequency = frequency + if public: + self._public = public + if owner_id: + self._owner_id = owner_id + if owner_name: + self._owner_name = owner_name + if view_id: + self._view_id = view_id + if view_name: + self._view_name = view_name + if workbook_id: + self._workbook_id = workbook_id + if workbook_name: + self._workbook_name = workbook_name + if project_id: + self._project_id = project_id + if project_name: + self._project_name = project_name + if recipients: + self._recipients = recipients + + @classmethod + def from_response(cls, resp, ns): + all_alert_items = list() + parsed_response = ET.fromstring(resp) + all_alert_xml = parsed_response.findall('.//t:dataAlert', namespaces=ns) + + for alert_xml in all_alert_xml: + kwargs = cls._parse_element(alert_xml, ns) + alert_item = cls() + alert_item._set_values(**kwargs) + all_alert_items.append(alert_item) + + return all_alert_items + + @staticmethod + def _parse_element(alert_xml, ns): + kwargs = dict() + kwargs['id'] = alert_xml.get('id', None) + kwargs['subject'] = alert_xml.get('subject', None) + kwargs['creatorId'] = alert_xml.get('creatorId', None) + kwargs['createdAt'] = alert_xml.get('createdAt', None) + kwargs['updatedAt'] = alert_xml.get('updatedAt', None) + kwargs['frequency'] = alert_xml.get('frequency', None) + kwargs['public'] = alert_xml.get('public', None) + + owner = alert_xml.findall('.//t:owner', namespaces=ns)[0] + kwargs['owner_id'] = owner.get('id', None) + kwargs['owner_name'] = owner.get('name', None) + + view_response = alert_xml.findall('.//t:view', namespaces=ns)[0] + kwargs['view_id'] = view_response.get('id', None) + kwargs['view_name'] = view_response.get('name', None) + + workbook_response = view_response.findall('.//t:workbook', namespaces=ns)[0] + kwargs['workbook_id'] = workbook_response.get('id', None) + kwargs['workbook_name'] = workbook_response.get('name', None) + project_response = view_response.findall('.//t:project', namespaces=ns)[0] + kwargs['project_id'] = project_response.get('id', None) + kwargs['project_name'] = project_response.get('name', None) + + recipients = alert_xml.findall('.//t:recipient', namespaces=ns) + kwargs['recipients'] = [recipient.get('id', None) for recipient in recipients] + + return kwargs diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index aff549559..afebafabe 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,11 +2,11 @@ from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions from .filter import Filter from .sort import Sort -from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ +from .. import ConnectionItem, DataAlertItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem -from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ +from .endpoint import Auth, DataAlerts, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError, Flows, Favorites from .server import Server diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 1341ecd3f..5d55509cf 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,5 +1,6 @@ from .auth_endpoint import Auth from .data_acceleration_report_endpoint import DataAccelerationReport +from .data_alert_endpoint import DataAlerts from .datasources_endpoint import Datasources from .databases_endpoint import Databases from .endpoint import Endpoint diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py new file mode 100644 index 000000000..7e43ae112 --- /dev/null +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -0,0 +1,87 @@ +from .endpoint import api, Endpoint +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .default_permissions_endpoint import _DefaultPermissionsEndpoint + +from .. import RequestFactory, DataAlertItem, TableItem, PaginationItem, Permission + +import logging + +logger = logging.getLogger('tableau.endpoint.dataalerts') + + +class DataAlerts(Endpoint): + def __init__(self, parent_srv): + super(DataAlerts, self).__init__(parent_srv) + + @property + def baseurl(self): + return "{0}/sites/{1}/dataalerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.2") + def get(self, req_options=None): + logger.info('Querying all dataalerts on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_dataalert_items = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace) + return all_dataalert_items, pagination_item + + # Get 1 dataalert + @api(version="3.2") + def get_by_id(self, dataalert_id): + if not dataalert_id: + error = "dataalert ID undefined." + raise ValueError(error) + logger.info('Querying single dataalert (ID: {0})'.format(dataalert_id)) + url = "{0}/{1}".format(self.baseurl, dataalert_id) + server_response = self.get_request(url) + return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.2") + def delete(self, dataalert): + dataalert_id = getattr(dataalert, 'id', dataalert) + if not dataalert_id: + error = "Dataalert ID undefined." + raise ValueError(error) + # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id + url = "{0}/{1}".format(self.baseurl, dataalert_id) + self.delete_request(url) + logger.info('Deleted single dataalert (ID: {0})'.format(dataalert_id)) + + @api(version="3.2") + def delete_user_from_alert(self, dataalert, user): + dataalert_id = getattr(dataalert, 'id', dataalert) + if not dataalert_id: + error = "Dataalert ID undefined." + raise ValueError(error) + # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id + url = "{0}/{1}/users/{2}".format(self.baseurl, dataalert_id, user.id) + self.delete_request(url) + logger.info('Deleted single dataalert (ID: {0})'.format(dataalert_id)) + + @api(version="3.2") + def add_user_to_alert(self, dataalert_item, user): + if not dataalert_item.id: + error = "Dataalert item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}/users".format(self.baseurl, dataalert_item.id) + update_req = RequestFactory.DataAlert.update_req(dataalert_item) + server_response = self.put_request(url, update_req) + logger.info('Updated dataalert item (ID: {0})'.format(dataalert_item.id)) + updated_dataalert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_dataalert + + @api(version="3.2") + def update(self, dataalert_item): + if not dataalert_item.id: + error = "Dataalert item missing ID." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self.baseurl, dataalert_item.id) + update_req = RequestFactory.DataAlert.update_req(dataalert_item) + server_response = self.put_request(url, update_req) + logger.info('Updated dataalert item (ID: {0})'.format(dataalert_item.id)) + updated_dataalert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_dataalert diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c1a54760a..36bd388b0 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -86,6 +86,28 @@ def update_req(self, column_item): return ET.tostring(xml_request) +class DataAlertRequest(object): + def add_user_to_alert(self, alert_item, user_item): + xml_request = ET.Element('tsRequest') + user_element = ET.SubElement(xml_request, 'user') + user_element.attrib['id'] = user_item.id + + return ET.tostring(xml_request) + + + def update_req(self, alert_item): + xml_request = ET.Element('tsRequest') + dataAlert_element = ET.SubElement(xml_request, 'dataAlert') + dataAlert_element.attrib['subject'] = alert_item.subject + dataAlert_element.attrib['frequency'] = alert_item.frequency + dataAlert_element.attrib['public'] = alert_item.public + + owner = ET.SubElement(dataAlert_element, 'owner') + owner.attrib['id'] = alert_item.owner_id + + return ET.tostring(xml_request) + + class DatabaseRequest(object): def update_req(self, database_item): xml_request = ET.Element('tsRequest') @@ -637,6 +659,7 @@ class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() + DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() Empty = EmptyRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index c36ee0f4b..6aff0c126 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, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites + Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites, DataAlerts from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -58,6 +58,7 @@ def __init__(self, server_address, use_server_version=False): self.tables = Tables(self) self.webhooks = Webhooks(self) self.data_acceleration_report = DataAccelerationReport(self) + self.data_alerts = DataAlerts(self) self._namespace = Namespace() if use_server_version: From 9b17bd4006ee4de3d6cc67b4d5a4cb9e7b9f7e43 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 13 Aug 2020 08:30:15 -0500 Subject: [PATCH 171/567] Test data alerts --- tableauserverclient/models/data_alert_item.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 13 +- tableauserverclient/server/request_factory.py | 1 - test/assets/data_alerts_add_user.xml | 7 ++ test/assets/data_alerts_get.xml | 14 +++ test/assets/data_alerts_get_by_id.xml | 17 +++ test/assets/data_alerts_update.xml | 14 +++ test/test_dataalert.py | 115 ++++++++++++++++++ 8 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 test/assets/data_alerts_add_user.xml create mode 100644 test/assets/data_alerts_get.xml create mode 100644 test/assets/data_alerts_get_by_id.xml create mode 100644 test/assets/data_alerts_update.xml create mode 100644 test/test_dataalert.py diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index fbce4077c..58038fdd0 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -25,7 +25,7 @@ def __init__(self): self._recipients = None def __repr__(self): - return "".format(self.id, self.subject, + return "".format(self.id, self.subject, self.frequency, self.public) @property diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 7e43ae112..82fa04855 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -3,7 +3,7 @@ from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .. import RequestFactory, DataAlertItem, TableItem, PaginationItem, Permission +from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem import logging @@ -52,11 +52,12 @@ def delete(self, dataalert): @api(version="3.2") def delete_user_from_alert(self, dataalert, user): dataalert_id = getattr(dataalert, 'id', dataalert) + user_id = getattr(user, 'id', user) if not dataalert_id: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataalert_id, user.id) + url = "{0}/{1}/users/{2}".format(self.baseurl, dataalert_id, user_id) self.delete_request(url) logger.info('Deleted single dataalert (ID: {0})'.format(dataalert_id)) @@ -67,11 +68,11 @@ def add_user_to_alert(self, dataalert_item, user): raise MissingRequiredFieldError(error) url = "{0}/{1}/users".format(self.baseurl, dataalert_item.id) - update_req = RequestFactory.DataAlert.update_req(dataalert_item) - server_response = self.put_request(url, update_req) + update_req = RequestFactory.DataAlert.add_user_to_alert(dataalert_item, user) + server_response = self.post_request(url, update_req) logger.info('Updated dataalert item (ID: {0})'.format(dataalert_item.id)) - updated_dataalert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return updated_dataalert + user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return user @api(version="3.2") def update(self, dataalert_item): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 36bd388b0..5075748d2 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -94,7 +94,6 @@ def add_user_to_alert(self, alert_item, user_item): return ET.tostring(xml_request) - def update_req(self, alert_item): xml_request = ET.Element('tsRequest') dataAlert_element = ET.SubElement(xml_request, 'dataAlert') diff --git a/test/assets/data_alerts_add_user.xml b/test/assets/data_alerts_add_user.xml new file mode 100644 index 000000000..2a367a7f1 --- /dev/null +++ b/test/assets/data_alerts_add_user.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_get.xml b/test/assets/data_alerts_get.xml new file mode 100644 index 000000000..78a55d4ca --- /dev/null +++ b/test/assets/data_alerts_get.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_get_by_id.xml b/test/assets/data_alerts_get_by_id.xml new file mode 100644 index 000000000..1a7456545 --- /dev/null +++ b/test/assets/data_alerts_get_by_id.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/data_alerts_update.xml b/test/assets/data_alerts_update.xml new file mode 100644 index 000000000..78a55d4ca --- /dev/null +++ b/test/assets/data_alerts_update.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_dataalert.py b/test/test_dataalert.py new file mode 100644 index 000000000..7822d3000 --- /dev/null +++ b/test/test_dataalert.py @@ -0,0 +1,115 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, read_xml_assets, asset + +GET_XML = 'data_alerts_get.xml' +GET_BY_ID_XML = 'data_alerts_get_by_id.xml' +ADD_USER_TO_ALERT = 'data_alerts_add_user.xml' +UPDATE_XML = 'data_alerts_update.xml' + + +class DataAlertTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.2" + + self.baseurl = self.server.data_alerts.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_alerts, pagination_item = self.server.data_alerts.get() + + self.assertEqual(1, pagination_item.total_available) + self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', all_alerts[0].id) + self.assertEqual('Data Alert test', all_alerts[0].subject) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].creatorId) + self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].createdAt) + self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].updatedAt) + self.assertEqual('Daily', all_alerts[0].frequency) + self.assertEqual('true', all_alerts[0].public) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].owner_id) + self.assertEqual('Bob', all_alerts[0].owner_name) + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_alerts[0].view_id) + self.assertEqual('ENDANGERED SAFARI', all_alerts[0].view_name) + self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_alerts[0].workbook_id) + self.assertEqual('Safari stats', all_alerts[0].workbook_name) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_alerts[0].project_id) + self.assertEqual('Default', all_alerts[0].project_name) + + def test_get_by_id(self): + response_xml = read_xml_asset(GET_BY_ID_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + alert = self.server.data_alerts.get_by_id('5ea59b45-e497-5673-8809-bfe213236f75') + + self.assertTrue(isinstance(alert.recipients, list)) + self.assertEqual(len(alert.recipients), 1) + self.assertEqual(alert.recipients[0], 'dd2239f6-ddf1-4107-981a-4cf94e415794') + + def test_update(self): + response_xml = read_xml_asset(UPDATE_XML) + with requests_mock.mock() as m: + m.put(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + single_alert = TSC.DataAlertItem() + single_alert._id = '5ea59b45-e497-5673-8809-bfe213236f75' + single_alert._subject = 'Data Alert test' + single_alert._frequency = 'Daily' + single_alert._public = "true" + single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + single_alert = self.server.data_alerts.update(single_alert) + + self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', single_alert.id) + self.assertEqual('Data Alert test', single_alert.subject) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.creatorId) + self.assertEqual('2020-08-10T23:17:06Z', single_alert.createdAt) + self.assertEqual('2020-08-10T23:17:06Z', single_alert.updatedAt) + self.assertEqual('Daily', single_alert.frequency) + self.assertEqual('true', single_alert.public) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.owner_id) + self.assertEqual('Bob', single_alert.owner_name) + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_alert.view_id) + self.assertEqual('ENDANGERED SAFARI', single_alert.view_name) + self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', single_alert.workbook_id) + self.assertEqual('Safari stats', single_alert.workbook_name) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', single_alert.project_id) + self.assertEqual('Default', single_alert.project_name) + + def test_add_user_to_alert(self): + response_xml = read_xml_asset(ADD_USER_TO_ALERT) + single_alert = TSC.DataAlertItem() + single_alert._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + in_user = TSC.UserItem('Bob', TSC.UserItem.Roles.Explorer) + in_user._id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + + with requests_mock.mock() as m: + m.post(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users', text=response_xml) + + out_user = self.server.data_alerts.add_user_to_alert(single_alert, in_user) + + self.assertEqual(out_user.id, in_user.id) + self.assertEqual(out_user.name, in_user.name) + self.assertEqual(out_user.site_role, in_user.site_role) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) + self.server.data_alerts.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + + def test_delete_user_from_alert(self): + alert_id = '5ea59b45-e497-5673-8809-bfe213236f75' + user_id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + with requests_mock.mock() as m: + m.delete(self.baseurl + '/{0}/users/{1}'.format(alert_id, user_id), status_code=204) + self.server.data_alerts.delete_user_from_alert(alert_id, user_id) From 036322ba3f4a140da900b51563b54db2073edf79 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 13 Aug 2020 10:33:42 -0500 Subject: [PATCH 172/567] Improve repr on data_alert --- .gitignore | 3 ++- tableauserverclient/models/data_alert_item.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5f5db36d7..36c353401 100644 --- a/.gitignore +++ b/.gitignore @@ -92,7 +92,8 @@ ENV/ # Rope project settings .ropeproject - +# VSCode project settings +.vscode/ # macOS.gitignore from https://github.com/github/gitignore *.DS_Store diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 58038fdd0..84789b884 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -25,8 +25,8 @@ def __init__(self): self._recipients = None def __repr__(self): - return "".format(self.id, self.subject, - self.frequency, self.public) + return "".format(**self.__dict__) @property def id(self): From 0133207f5270afdb88aac7348914b8e4b7028a1b Mon Sep 17 00:00:00 2001 From: absentmoose <32021884+absentmoose@users.noreply.github.com> Date: Fri, 14 Aug 2020 11:35:02 -0500 Subject: [PATCH 173/567] Added webpage url to workbooks (#661) * Fixes login sample to pass in sitename for username/password auth (cherry picked from commit 4cbd8007f64dbc93e1bf89981f5cfbcec01f83bb) * Fixes default sitename in login sample and adds more print statements (#652) (cherry picked from commit ccbbc49b5278d6c4605262612cb18ebd265aea0a) * Updates changelog with patch notes * Added webpage_url to workbooks * removed vscode files * Update workbook_item.py Removing whitespace * Update test_workbook.py Removing cells * Update test_workbook.py Removing whitespace * Update workbook_get_by_id.xml Adding webpage url * Create notes property from XML response (#571) Create notes property from XML response Co-authored-by: Jordan Woods * Minor edits and cleanup * Rename a duplicate test method so they all run * Cleanup test comments and descriptions, no functional changes * Add support for Python 3.8 Only test_publish_with_hidden_view() needed changes because the order of attributes in the XML request body were swapped for some reason in 3.8 compared to prior Pythons. Co-authored-by: Chris Shin Co-authored-by: Rickey Shideler Co-authored-by: jorwoods Co-authored-by: Jordan Woods Co-authored-by: Brian Cantoni --- CHANGELOG.md | 4 ++++ tableauserverclient/models/workbook_item.py | 20 ++++++++++++++------ test/assets/workbook_get.xml | 5 ++--- test/assets/workbook_get_by_id.xml | 4 ++-- test/test_workbook.py | 3 +++ 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa01dac19..d0da3f294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.12.1 (22 July 2020) + +* Fixed login.py sample to properly handle sitename (#652) + ## 0.12 (10 July 2020) * Added hidden_views parameter to workbook publish method (#614) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index a7decd41f..3a3ddcdf9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -12,6 +12,7 @@ class WorkbookItem(object): def __init__(self, project_id, name=None, show_tabs=False): self._connections = None self._content_url = None + self._webpage_url = None self._created_at = None self._id = None self._initial_tags = set() @@ -51,6 +52,10 @@ def permissions(self): def content_url(self): return self._content_url + @property + def webpage_url(self): + return self._webpage_url + @property def created_at(self): return self._created_at @@ -152,17 +157,17 @@ def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) if workbook_xml is not None: - (_, _, _, _, description, updated_at, _, show_tabs, + (_, _, _, _, _, description, updated_at, _, show_tabs, project_id, project_name, owner_id, _, _, data_acceleration_config) = self._parse_element(workbook_xml, ns) - self._set_values(None, None, None, None, description, updated_at, + self._set_values(None, None, None, None, None, description, updated_at, None, show_tabs, project_id, project_name, owner_id, None, None, data_acceleration_config) return self - def _set_values(self, id, name, content_url, created_at, description, updated_at, + def _set_values(self, id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, data_acceleration_config): if id is not None: @@ -171,6 +176,8 @@ def _set_values(self, id, name, content_url, created_at, description, updated_at self.name = name if content_url: self._content_url = content_url + if webpage_url: + self._webpage_url = webpage_url if created_at: self._created_at = created_at if description: @@ -201,12 +208,12 @@ def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) all_workbook_xml = parsed_response.findall('.//t:workbook', namespaces=ns) for workbook_xml in all_workbook_xml: - (id, name, content_url, created_at, description, updated_at, size, show_tabs, + (id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, project_id, project_name, owner_id, tags, views, data_acceleration_config) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) - workbook_item._set_values(id, name, content_url, created_at, description, updated_at, + workbook_item._set_values(id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, None, project_name, owner_id, tags, views, data_acceleration_config) all_workbook_items.append(workbook_item) @@ -217,6 +224,7 @@ def _parse_element(workbook_xml, ns): id = workbook_xml.get('id', None) name = workbook_xml.get('name', None) content_url = workbook_xml.get('contentUrl', None) + webpage_url = workbook_xml.get('webpageUrl', None) created_at = parse_datetime(workbook_xml.get('createdAt', None)) description = workbook_xml.get('description', None) updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) @@ -256,7 +264,7 @@ def _parse_element(workbook_xml, ns): if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) - return id, name, content_url, created_at, description, updated_at, size, show_tabs, \ + return id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, \ project_id, project_name, owner_id, tags, views, data_acceleration_config diff --git a/test/assets/workbook_get.xml b/test/assets/workbook_get.xml index e5fd3967b..873ca3848 100644 --- a/test/assets/workbook_get.xml +++ b/test/assets/workbook_get.xml @@ -2,13 +2,12 @@ - + - - + diff --git a/test/assets/workbook_get_by_id.xml b/test/assets/workbook_get_by_id.xml index 1b2fe9120..98dfc4a75 100644 --- a/test/assets/workbook_get_by_id.xml +++ b/test/assets/workbook_get_by_id.xml @@ -1,6 +1,6 @@ - + @@ -11,4 +11,4 @@
- \ No newline at end of file + diff --git a/test/test_workbook.py b/test/test_workbook.py index 6f0e7f18a..1a68f3437 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -56,6 +56,7 @@ def test_get(self): self.assertEqual('Superstore', all_workbooks[0].name) self.assertEqual('Superstore', all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) + self.assertEqual('http://tableauserver/#/workbooks/1/views', all_workbooks[0].webpage_url) self.assertEqual(1, all_workbooks[0].size) self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) self.assertEqual('description for Superstore', all_workbooks[0].description) @@ -67,6 +68,7 @@ def test_get(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_workbooks[1].id) self.assertEqual('SafariSample', all_workbooks[1].name) self.assertEqual('SafariSample', all_workbooks[1].content_url) + self.assertEqual('http://tableauserver/#/workbooks/2/views', all_workbooks[1].webpage_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) @@ -101,6 +103,7 @@ def test_get_by_id(self): self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', single_workbook.id) self.assertEqual('SafariSample', single_workbook.name) self.assertEqual('SafariSample', single_workbook.content_url) + self.assertEqual('http://tableauserver/#/workbooks/2/views', single_workbook.webpage_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) From 3f1b85189f23a412fd240e01edc4e4a73c368e38 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 14 Aug 2020 14:44:41 -0500 Subject: [PATCH 174/567] Resolve comments --- tableauserverclient/models/data_alert_item.py | 11 +++++++++-- .../server/endpoint/data_alert_endpoint.py | 14 ++++++++++---- tableauserverclient/server/request_factory.py | 4 ++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 84789b884..c1adeba3a 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,11 +1,18 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_not_empty +from .property_decorators import property_not_empty, property_is_enum from .user_item import UserItem from .view_item import ViewItem class DataAlertItem(object): + class Frequency: + Once = 'once' + Frequently = 'frequently' + Hourly = 'hourly' + Daily = 'daily' + Weekly = 'weekly' + def __init__(self): self._id = None self._subject = None @@ -46,7 +53,7 @@ def frequency(self): return self._frequency @frequency.setter - @property_not_empty + @property_is_enum(Frequency) def frequency(self, value): self._frequency = value diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 82fa04855..e6988da57 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -56,21 +56,27 @@ def delete_user_from_alert(self, dataalert, user): if not dataalert_id: error = "Dataalert ID undefined." raise ValueError(error) + if not user_id: + error = "User ID undefined." + raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id url = "{0}/{1}/users/{2}".format(self.baseurl, dataalert_id, user_id) self.delete_request(url) - logger.info('Deleted single dataalert (ID: {0})'.format(dataalert_id)) + logger.info('Deleted User (ID {0}) from dataalert (ID: {1})'.format(user_id, dataalert_id)) @api(version="3.2") def add_user_to_alert(self, dataalert_item, user): if not dataalert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - + user_id = getattr(user, 'id', user) + if not user_id: + error = "User ID undefined." + raise ValueError(error) url = "{0}/{1}/users".format(self.baseurl, dataalert_item.id) - update_req = RequestFactory.DataAlert.add_user_to_alert(dataalert_item, user) + update_req = RequestFactory.DataAlert.add_user_to_alert(dataalert_item, user_id) server_response = self.post_request(url, update_req) - logger.info('Updated dataalert item (ID: {0})'.format(dataalert_item.id)) + logger.info('Added user (ID {0}) to dataalert item (ID: {1})'.format(user_id, dataalert_item.id)) user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return user diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 5075748d2..5d26b71f9 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -87,10 +87,10 @@ def update_req(self, column_item): class DataAlertRequest(object): - def add_user_to_alert(self, alert_item, user_item): + def add_user_to_alert(self, alert_item, user_id): xml_request = ET.Element('tsRequest') user_element = ET.SubElement(xml_request, 'user') - user_element.attrib['id'] = user_item.id + user_element.attrib['id'] = user_id return ET.tostring(xml_request) From f8799baca1a3c88c923f47b2f3899ae7172292b1 Mon Sep 17 00:00:00 2001 From: Paul Vickers Date: Fri, 14 Aug 2020 22:07:33 +0100 Subject: [PATCH 175/567] parse_datetime() should gracefully handle invalid dates (#529) * parse_datetime() should gracefully handle invalid dates We've seen instances where enumerating workbooks on our server results in an exception in parse_datetime(). It appears that some workbooks contain invalid dates e.g. `2018-06-31T13:12:00Z` for their created_at or updated_at times which causes the method to throw an exception, breaking enumeration. This change simply catches the parsing failure and returns `None` in that scenario. * Handle date is `None` in format_datetime() * Add test XML file with invalid date * Add test for invalid date * Add missing constant * Remove whitespace --- tableauserverclient/datetime_helpers.py | 8 +++++++- test/assets/workbook_get_invalid_date.xml | 11 +++++++++++ test/test_workbook.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 test/assets/workbook_get_invalid_date.xml diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 95041f8e1..2b1df202c 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -28,8 +28,14 @@ def parse_datetime(date): if date is None: return None - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None def format_datetime(date): + if date is None: + return None + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/test/assets/workbook_get_invalid_date.xml b/test/assets/workbook_get_invalid_date.xml new file mode 100644 index 000000000..c580f9eb6 --- /dev/null +++ b/test/assets/workbook_get_invalid_date.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a68f3437..58880b7b1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -20,6 +20,7 @@ ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_add_tags.xml') GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') +GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_invalid_date.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') @@ -79,6 +80,15 @@ def test_get(self): self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[1].owner_id) self.assertEqual(set(['Safari', 'Sample']), all_workbooks[1].tags) + def test_get_ignore_invalid_date(self): + with open(GET_INVALID_DATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_workbooks, pagination_item = self.server.workbooks.get() + self.assertEqual(None, format_datetime(all_workbooks[0].created_at)) + self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) + def test_get_before_signin(self): self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.workbooks.get) From 972f10aad7300e654955b44e71949c77b9918c8d Mon Sep 17 00:00:00 2001 From: jorwoods Date: Fri, 14 Aug 2020 16:44:13 -0500 Subject: [PATCH 176/567] Change update_permission endpoint for consistency (#668) `update_permission` will throw a warning but continue to work. `update_permissions` is now standard for all content types --- tableauserverclient/server/endpoint/databases_endpoint.py | 8 ++++++++ .../server/endpoint/datasources_endpoint.py | 8 ++++++++ tableauserverclient/server/endpoint/flows_endpoint.py | 8 ++++++++ tableauserverclient/server/endpoint/projects_endpoint.py | 8 ++++++++ tableauserverclient/server/endpoint/tables_endpoint.py | 8 ++++++++ 5 files changed, 40 insertions(+) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 85dd406ef..6f15ed0d1 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -89,6 +89,14 @@ def populate_permissions(self, item): @api(version='3.5') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.databases.update_permission is deprecated, ' + 'please use Server.databases.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='3.5') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='3.5') diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7a00157fe..02f5a57b0 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -227,6 +227,14 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, permission_item): + import warnings + warnings.warn('Server.datasources.update_permission is deprecated, ' + 'please use Server.datasources.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) + + @api(version='2.0') + def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='2.0') diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 44a110e7e..dfe16f904 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -204,6 +204,14 @@ def populate_permissions(self, item): @api(version='3.3') def update_permission(self, item, permission_item): + import warnings + warnings.warn('Server.flows.update_permission is deprecated, ' + 'please use Server.flows.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) + + @api(version='3.3') + def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='3.3') diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index a7f22795c..170425eab 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -67,6 +67,14 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.projects.update_permission is deprecated, ' + 'please use Server.projects.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='2.0') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='2.0') diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 032f13016..3a5c2f3f4 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -100,6 +100,14 @@ def populate_permissions(self, item): @api(version='3.5') def update_permission(self, item, rules): + import warnings + warnings.warn('Server.tables.update_permission is deprecated, ' + 'please use Server.tables.update_permissions instead.', + DeprecationWarning) + return self._permissions.update(item, rules) + + @api(version='3.5') + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version='3.5') From d205b4c4972c5d01ed6e9c2e952bf748e09f43f2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 14 Aug 2020 16:53:30 -0500 Subject: [PATCH 177/567] CamelCase consistency --- .../server/endpoint/data_alert_endpoint.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index e6988da57..b28ec14c4 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ import logging -logger = logging.getLogger('tableau.endpoint.dataalerts') +logger = logging.getLogger('tableau.endpoint.dataAlerts') class DataAlerts(Endpoint): @@ -16,79 +16,79 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/dataalerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") def get(self, req_options=None): - logger.info('Querying all dataalerts on site') + logger.info('Querying all dataAlerts on site') url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - all_dataalert_items = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace) - return all_dataalert_items, pagination_item + all_dataAlert_items = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace) + return all_dataAlert_items, pagination_item - # Get 1 dataalert + # Get 1 dataAlert @api(version="3.2") - def get_by_id(self, dataalert_id): - if not dataalert_id: - error = "dataalert ID undefined." + def get_by_id(self, dataAlert_id): + if not dataAlert_id: + error = "dataAlert ID undefined." raise ValueError(error) - logger.info('Querying single dataalert (ID: {0})'.format(dataalert_id)) - url = "{0}/{1}".format(self.baseurl, dataalert_id) + logger.info('Querying single dataAlert (ID: {0})'.format(dataAlert_id)) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.2") - def delete(self, dataalert): - dataalert_id = getattr(dataalert, 'id', dataalert) - if not dataalert_id: + def delete(self, dataAlert): + dataAlert_id = getattr(dataAlert, 'id', dataAlert) + if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataalert_id) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) self.delete_request(url) - logger.info('Deleted single dataalert (ID: {0})'.format(dataalert_id)) + logger.info('Deleted single dataAlert (ID: {0})'.format(dataAlert_id)) @api(version="3.2") - def delete_user_from_alert(self, dataalert, user): - dataalert_id = getattr(dataalert, 'id', dataalert) + def delete_user_from_alert(self, dataAlert, user): + dataAlert_id = getattr(dataAlert, 'id', dataAlert) user_id = getattr(user, 'id', user) - if not dataalert_id: + if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) if not user_id: error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataalert_id, user_id) + url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) self.delete_request(url) - logger.info('Deleted User (ID {0}) from dataalert (ID: {1})'.format(user_id, dataalert_id)) + logger.info('Deleted User (ID {0}) from dataAlert (ID: {1})'.format(user_id, dataAlert_id)) @api(version="3.2") - def add_user_to_alert(self, dataalert_item, user): - if not dataalert_item.id: + def add_user_to_alert(self, dataAlert_item, user): + if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) user_id = getattr(user, 'id', user) if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataalert_item.id) - update_req = RequestFactory.DataAlert.add_user_to_alert(dataalert_item, user_id) + url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info('Added user (ID {0}) to dataalert item (ID: {1})'.format(user_id, dataalert_item.id)) + logger.info('Added user (ID {0}) to dataAlert item (ID: {1})'.format(user_id, dataAlert_item.id)) user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return user @api(version="3.2") - def update(self, dataalert_item): - if not dataalert_item.id: + def update(self, dataAlert_item): + if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataalert_item.id) - update_req = RequestFactory.DataAlert.update_req(dataalert_item) + url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info('Updated dataalert item (ID: {0})'.format(dataalert_item.id)) - updated_dataalert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return updated_dataalert + logger.info('Updated dataAlert item (ID: {0})'.format(dataAlert_item.id)) + updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return updated_dataAlert From 79c355af9e346f7f825c9ea34c6bcdfb173a5088 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 14 Aug 2020 16:55:38 -0500 Subject: [PATCH 178/567] Validate property values --- tableauserverclient/models/data_alert_item.py | 14 +++++++------- tableauserverclient/server/request_factory.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index c1adeba3a..559050b4b 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,17 +1,17 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_not_empty, property_is_enum +from .property_decorators import property_not_empty, property_is_enum, property_is_boolean from .user_item import UserItem from .view_item import ViewItem class DataAlertItem(object): class Frequency: - Once = 'once' - Frequently = 'frequently' - Hourly = 'hourly' - Daily = 'daily' - Weekly = 'weekly' + Once = 'Once' + Frequently = 'Frequently' + Hourly = 'Hourly' + Daily = 'Daily' + Weekly = 'Weekly' def __init__(self): self._id = None @@ -62,7 +62,7 @@ def public(self): return self._public @public.setter - @property_not_empty + @property_is_boolean def public(self, value): self._public = value diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 5d26b71f9..a1b0f1c26 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -98,7 +98,7 @@ def update_req(self, alert_item): xml_request = ET.Element('tsRequest') dataAlert_element = ET.SubElement(xml_request, 'dataAlert') dataAlert_element.attrib['subject'] = alert_item.subject - dataAlert_element.attrib['frequency'] = alert_item.frequency + dataAlert_element.attrib['frequency'] = alert_item.frequency.lower() dataAlert_element.attrib['public'] = alert_item.public owner = ET.SubElement(dataAlert_element, 'owner') From c395c1858d97a45688e2c5635e466a551c8f695f Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 01:41:15 -0700 Subject: [PATCH 179/567] create/delete encrypted extracts for ds and wb --- CHANGELOG.md | 3 ++ .../server/endpoint/datasources_endpoint.py | 25 ++++++++---- .../server/endpoint/sites_endpoint.py | 30 +++++++++++++++ .../server/endpoint/workbooks_endpoint.py | 20 ++++++++++ tableauserverclient/server/request_factory.py | 13 ++++++- test/test_datasource.py | 27 +++++++++++++ test/test_site.py | 16 ++++++++ test/test_workbook.py | 38 ++++++++++++++++++- 8 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0da3f294..59e86f37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.13 (19 Aug 2020) +* Add support for basic Extract operations - Create, Delete, en/re/decrypt for site + ## 0.12.1 (22 July 2020) * Fixed login.py sample to properly handle sitename (#652) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 02f5a57b0..ef91285f8 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,6 +151,23 @@ def refresh(self, datasource_item): server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + + @api(version='3.5') + def create_extract(self, datasource_item, encrypt=False): + id_ = getattr(datasource_item, 'id', datasource_item) + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + @api(version='3.5') + def delete_extract(self, datasource_item): + id_ = getattr(datasource_item, 'id', datasource_item) + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url, empty_req) + # Publish datasource @api(version="2.0") @@ -227,14 +244,6 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, permission_item): - import warnings - warnings.warn('Server.datasources.update_permission is deprecated, ' - 'please use Server.datasources.update_permissions instead.', - DeprecationWarning) - self._permissions.update(item, permission_item) - - @api(version='2.0') - def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='2.0') diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 8a6212a28..89c57533a 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -103,3 +103,33 @@ def create(self, site_item): new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info('Created new site (ID: {0})'.format(new_site.id)) return new_site + + @api(version="3.5") + def encrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url,empty_req) + + + @api(version="3.5") + def decrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url,empty_req) + + + @api(version="3.5") + def re_encrypt_extracts(self, site_id): + if not site_id: + error = "Site ID undefined." + raise ValueError(error) + url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + + empty_req = RequestFactory.Empty.empty_req() + self.post_request(url,empty_req) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 82a5f9cd0..680389ce6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -61,6 +61,26 @@ def refresh(self, workbook_id): new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + + @api(version='3.5') + def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): + id_ = getattr(workbook_item, 'id', workbook_item) + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + + datasource_req = RequestFactory.WorkbookR.embedded_extract_req(includeAll, datasources) + + server_response = self.post_request(url, datasource_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + + @api(version='3.5') + def delete_extract(self, workbook_item): + id_ = getattr(workbook_item, 'id', workbook_item) + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + + # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c1a54760a..4ec526074 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -569,6 +569,17 @@ def publish_req_chunked( return _add_multipart(parts) + @_tsrequest_wrapped + def embedded_extract_req(self, xml_request, include_all=True, datasources=None): + list_element = ET.SubElement(xml_request, 'datasources') + if include_all: + list_element.attrib['includeAll']="true" + else: + for datasource_item in datasources: + datasource_element = list_element.SubElement(xml_request, 'datasource') + datasource_element.attrib['id'] = datasource_item.id + + class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request, connection_item): @@ -623,7 +634,7 @@ def create_req(self, xml_request, webhook_item): webhook.attrib['name'] = webhook_item.name source = ET.SubElement(webhook, 'webhook-source') - event = ET.SubElement(source, webhook_item._event) + ET.SubElement(source, webhook_item._event) destination = ET.SubElement(webhook, 'webhook-destination') post = ET.SubElement(destination, 'webhook-destination-http') diff --git a/test/test_datasource.py b/test/test_datasource.py index 2b7cc623c..21c62a750 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -381,3 +381,30 @@ def test_synchronous_publish_timeout_error(self): self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', self.server.datasources.publish, new_datasource, asset('SampleDS.tds'), publish_mode) + + def test_delete_extracts(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) + self.server.datasources.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts_encrypted(self): + self.server.version = "3.10" + self.baseurl = self.server.datasources.baseurl + + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', True) + + diff --git a/test/test_site.py b/test/test_site.py index 09063b861..da4547cfa 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -15,6 +15,7 @@ class SiteTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') + self.server.version = "3.10" # Fake signin self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -140,3 +141,18 @@ def test_delete(self): def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.sites.delete, '') + + def test_encrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts', status_code=200) + self.server.sites.encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + + def test_recrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts', status_code=200) + self.server.sites.re_encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + + def test_decrypt(self): + with requests_mock.mock() as m: + m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts', status_code=200) + self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index 58880b7b1..b0c114eff 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -470,8 +470,11 @@ def test_publish_with_hidden_view(self): hidden_views=['GDP per capita']) request_body = m._adapter.request_history[0]._request.body - self.assertTrue(re.search(rb'<\/views>', request_body)) - self.assertTrue(re.search(rb'<\/views>', request_body)) + # order of attributes in xml is unspecified + self.assertTrue( + (b'' in request_body) + or + (b'' in request_body)) def test_publish_async(self): self.server.version = '3.0' @@ -566,3 +569,34 @@ def test_synchronous_publish_timeout_error(self): self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode) + + def test_delete_extracts_all(self): + self.server.version = "3.10" + self.baseurl = self.server.workbooks.baseurl + with requests_mock.mock() as m: + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) + self.server.workbooks.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + def test_create_extracts_all(self): + self.server.version = "3.10" + self.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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + + + def test_create_extracts_one(self): + self.server.version = "3.10" + self.baseurl = self.server.workbooks.baseurl + + datasource = TSC.DatasourceItem('test') + datasource._id = '1f951daf-4061-451a-9df1-69a8062664f2' + + 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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource) \ No newline at end of file From bfe49c7577c8e936b997f8194296bc9c9a2c0304 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 02:18:11 -0700 Subject: [PATCH 180/567] fix merge --- .../server/endpoint/datasources_endpoint.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index ef91285f8..f3490a896 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -244,6 +244,14 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, permission_item): + import warnings + warnings.warn('Server.datasources.update_permission is deprecated, ' + 'please use Server.datasources.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) + + @api(version='2.0') + def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) @api(version='2.0') From fb3e6fff8a9c4ba9aad4c8b105a03e5bfec61aa6 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 02:20:49 -0700 Subject: [PATCH 181/567] fixup! fix merge --- .../server/endpoint/datasources_endpoint.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index f3490a896..7f74e3661 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -244,13 +244,13 @@ def populate_permissions(self, item): @api(version='2.0') def update_permission(self, item, permission_item): - import warnings - warnings.warn('Server.datasources.update_permission is deprecated, ' - 'please use Server.datasources.update_permissions instead.', - DeprecationWarning) - self._permissions.update(item, permission_item) + import warnings + warnings.warn('Server.datasources.update_permission is deprecated, ' + 'please use Server.datasources.update_permissions instead.', + DeprecationWarning) + self._permissions.update(item, permission_item) - @api(version='2.0') + @api(version='2.0') def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) From fdc403b8cb766a658974eca5c9d928a2ff60443f Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 02:24:45 -0700 Subject: [PATCH 182/567] remove randomly added typo --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 680389ce6..125df41e2 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -67,7 +67,7 @@ def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasour id_ = getattr(workbook_item, 'id', workbook_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) - datasource_req = RequestFactory.WorkbookR.embedded_extract_req(includeAll, datasources) + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] From 12ca9fe126d9770df84b681378d414ed6dc08c07 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 02:41:23 -0700 Subject: [PATCH 183/567] ran through pycodestyle --- .../server/endpoint/datasources_endpoint.py | 3 +-- .../server/endpoint/sites_endpoint.py | 10 ++++------ .../server/endpoint/workbooks_endpoint.py | 5 ++--- tableauserverclient/server/request_factory.py | 3 +-- test/test_datasource.py | 12 ++++++------ test/test_site.py | 4 ++-- test/test_workbook.py | 15 ++++++++------- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f74e3661..87afb4914 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -151,7 +151,7 @@ def refresh(self, datasource_item): server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job - + @api(version='3.5') def create_extract(self, datasource_item, encrypt=False): id_ = getattr(datasource_item, 'id', datasource_item) @@ -167,7 +167,6 @@ def delete_extract(self, datasource_item): url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) - # Publish datasource @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 89c57533a..c57cb3d4f 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -111,8 +111,7 @@ def encrypt_extracts(self, site_id): raise ValueError(error) url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() - self.post_request(url,empty_req) - + self.post_request(url, empty_req) @api(version="3.5") def decrypt_extracts(self, site_id): @@ -121,8 +120,7 @@ def decrypt_extracts(self, site_id): raise ValueError(error) url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() - self.post_request(url,empty_req) - + self.post_request(url, empty_req) @api(version="3.5") def re_encrypt_extracts(self, site_id): @@ -130,6 +128,6 @@ def re_encrypt_extracts(self, site_id): error = "Site ID undefined." raise ValueError(error) url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) - + empty_req = RequestFactory.Empty.empty_req() - self.post_request(url,empty_req) + self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 125df41e2..2bde77dc9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -61,25 +61,24 @@ def refresh(self, workbook_id): new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job - + # create one or more extracts on 1 workbook, optionally encrypted @api(version='3.5') def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): id_ = getattr(workbook_item, 'id', workbook_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) - server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + # delete all the extracts on 1 workbook @api(version='3.5') def delete_extract(self, workbook_item): id_ = getattr(workbook_item, 'id', workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) - # Delete 1 workbook by id @api(version="2.0") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4ec526074..6dd43351a 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -568,12 +568,11 @@ def publish_req_chunked( parts = {'request_payload': ('', xml_request, 'text/xml')} return _add_multipart(parts) - @_tsrequest_wrapped def embedded_extract_req(self, xml_request, include_all=True, datasources=None): list_element = ET.SubElement(xml_request, 'datasources') if include_all: - list_element.attrib['includeAll']="true" + list_element.attrib['includeAll'] = "true" else: for datasource_item in datasources: datasource_element = list_element.SubElement(xml_request, 'datasource') diff --git a/test/test_datasource.py b/test/test_datasource.py index 21c62a750..7c6be6f67 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -392,19 +392,19 @@ def test_delete_extracts(self): def test_create_extracts(self): self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl - + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') def test_create_extracts_encrypted(self): self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl - + response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', True) - - diff --git a/test/test_site.py b/test/test_site.py index da4547cfa..a06876e2a 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -151,8 +151,8 @@ def test_recrypt(self): with requests_mock.mock() as m: m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts', status_code=200) self.server.sites.re_encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') - + def test_decrypt(self): with requests_mock.mock() as m: m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts', status_code=200) - self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') \ No newline at end of file + self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') diff --git a/test/test_workbook.py b/test/test_workbook.py index b0c114eff..8170eb0a1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -580,23 +580,24 @@ def test_delete_extracts_all(self): def test_create_extracts_all(self): self.server.version = "3.10" self.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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') - def test_create_extracts_one(self): self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl - + datasource = TSC.DatasourceItem('test') datasource._id = '1f951daf-4061-451a-9df1-69a8062664f2' - + 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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', status_code=200, text=response_xml) - self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource) \ No newline at end of file + m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', + status_code=200, text=response_xml) + self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource) From 81c795bcb951462ced7cb93a40ef8c8218cedaed Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2020 16:47:58 -0700 Subject: [PATCH 184/567] Update test_workbook.py add back regex test format that I ... lost in merge? --- test/test_workbook.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/test_workbook.py b/test/test_workbook.py index 8170eb0a1..8c361e713 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -471,10 +471,8 @@ def test_publish_with_hidden_view(self): request_body = m._adapter.request_history[0]._request.body # order of attributes in xml is unspecified - self.assertTrue( - (b'' in request_body) - or - (b'' in request_body)) + self.assertTrue(re.search(rb'<\/views>', request_body)) + self.assertTrue(re.search(rb'<\/views>', request_body)) def test_publish_async(self): self.server.version = '3.0' From a92e484bdc863703569b42b2a2769c5ea2a11ae0 Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 22 Aug 2020 20:55:46 -0700 Subject: [PATCH 185/567] implement ad groups and group options --- tableauserverclient/models/group_item.py | 34 +++++++++++++++++++ tableauserverclient/models/job_item.py | 15 ++++++-- .../server/endpoint/groups_endpoint.py | 28 +++++++++++---- tableauserverclient/server/request_factory.py | 31 +++++++++++++++-- test/assets/group_create_ad.xml | 10 ++++++ test/test_group.py | 25 ++++++++++++++ 6 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 test/assets/group_create_ad.xml diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index d37769006..77890227d 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -13,11 +13,17 @@ def __init__(self, name=None): self._id = None self._users = None self.name = name + self._license_mode = None + self._minimum_site_role = None @property def domain_name(self): return self._domain_name + @domain_name.setter + def domain_name(self, value): + self._domain_name = value + @property def id(self): return self._id @@ -31,6 +37,23 @@ def name(self): def name(self, value): self._name = value + @property + def license_mode(self): + return self._license_mode + + @license_mode.setter + def license_mode(self, value): + # valid values = onSync, onLogin + self._license_mode = value + + @property + def minimum_site_role(self): + return self._minimum_site_role + + @minimum_site_role.setter + def minimum_site_role(self, value): + self._minimum_site_role = value + @property def users(self): if self._users is None: @@ -54,7 +77,18 @@ def from_response(cls, resp, ns): name = group_xml.get('name', None) group_item = cls(name) group_item._id = group_xml.get('id', None) + # AD groups have an extra element under this + import_elem = group_xml.find('.//t:import', namespaces=ns) + if (import_elem is not None): + group_item.domain_name = import_elem.get('domainName') + group_item.license_mode = import_elem.get('grantLicenseMode') + group_item.minimum_site_role = import_elem.get('siteRole') + else: + # local group, we will just have two extra attributes here + group_item._license_mode = group_xml.get('grantLicenseMode') + group_item._minimum_site_role = group_xml.get('siteRole') + # what is this stuff? when would there be a domain element in a group? domain_elem = group_xml.find('.//t:domain', namespaces=ns) if domain_elem is not None: group_item._domain_name = domain_elem.get('name', None) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc8b7df43..985907ba3 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -4,7 +4,7 @@ class JobItem(object): def __init__(self, id_, job_type, progress, created_at, started_at=None, - completed_at=None, finish_code=0, notes=None): + completed_at=None, finish_code=0, notes=None, mode=None): self._id = id_ self._type = job_type self._progress = progress @@ -13,6 +13,7 @@ def __init__(self, id_, job_type, progress, created_at, started_at=None, self._completed_at = completed_at self._finish_code = finish_code self._notes = notes or [] + self._mode = mode @property def id(self): @@ -46,6 +47,15 @@ def finish_code(self): def notes(self): return self._notes + @property + def mode(self): + return self._mode + + @mode.setter + def mode(self, value): + # check for valid data here + self._mode = value + def __repr__(self): return "".format(**self.__dict__) @@ -71,7 +81,8 @@ def _parse_element(cls, element, ns): finish_code = element.get('finishCode', -1) notes = [note.text for note in element.findall('.//t:notes', namespaces=ns)] or None - return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes) + mode = element.get('mode', None) + return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes, mode) class BackgroundJobItem(object): diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index e0acb4477..6b023e4dd 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, GroupItem, UserItem, PaginationItem +from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager import logging @@ -58,7 +58,7 @@ def delete(self, group_id): logger.info('Deleted single group (ID: {0})'.format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=UNLICENSED_USER): + def update(self, group_item, default_site_role=UNLICENSED_USER, asJob=False): if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -66,17 +66,32 @@ def update(self, group_item, default_site_role=UNLICENSED_USER): update_req = RequestFactory.Group.update_req(group_item, default_site_role) server_response = self.put_request(url, update_req) logger.info('Updated group item (ID: {0})'.format(group_item.id)) - updated_group = GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return updated_group + if (asJob): + return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + else: + return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item): url = self.baseurl - create_req = RequestFactory.Group.create_req(group_item) + create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # Create a group based on Active Directory + @api(version="2.0") + def create_AD_group(self, group_item, asJob=False): + asJobparameter = "?asJob=true" if asJob else "" + url = self.baseurl + asJobparameter + create_req = RequestFactory.Group.create_ad_req(group_item) + server_response = self.post_request(url, create_req) + if (asJob): + return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + else: + return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item, user_id): @@ -102,5 +117,6 @@ def add_user(self, group_item, user_id): url = "{0}/{1}/users".format(self.baseurl, group_item.id) add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) - return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() + user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) + return user \ No newline at end of file diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6dd43351a..f91982193 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -238,13 +238,36 @@ def add_user_req(self, user_id): user_element.attrib['id'] = user_id return ET.tostring(xml_request) - def create_req(self, group_item): + def create_local_req(self, group_item): xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name + if (group_item.license_mode is not None): + group_element.attrib['grantLicenseMode'] = group_item.license_mode + if (group_item.minimum_site_role is not None): + group_element.attrib['SiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item, default_site_role): + def create_ad_req(self, group_item): + xml_request = ET.Element('tsRequest') + group_element = ET.SubElement(xml_request, 'group') + group_element.attrib['name'] = group_item.name + import_element = ET.SubElement(group_element, 'import') + import_element.attrib['source'] = "ActiveDirectory" + if (group_item.domain_name is None): + error = "Group Domain undefined." + raise ValueError(error) + + import_element.attrib['domainName'] = group_item.domain_name + if (group_item.license_mode is not None): + import_element.attrib['grantLicenseMode'] = group_item.license + if (group_item.minimum_site_role is not None): + import_element.attrib['SiteRole'] = group_item.minimum_site_role + return ET.tostring(xml_request) + + def update_req(self, group_item, default_site_role=None): + if (default_site_role is not None): + group_item.minimum_site_role = default_site_role xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name @@ -252,7 +275,9 @@ def update_req(self, group_item, default_site_role): project_element = ET.SubElement(group_element, 'import') project_element.attrib['source'] = "ActiveDirectory" project_element.attrib['domainName'] = group_item.domain_name - project_element.attrib['siteRole'] = default_site_role + project_element.attrib['siteRole'] = group_item.minimum_site_role + project_element.attrib['grantLicenseMode'] = group_item.license_mode + return ET.tostring(xml_request) diff --git a/test/assets/group_create_ad.xml b/test/assets/group_create_ad.xml new file mode 100644 index 000000000..26ddd94b0 --- /dev/null +++ b/test/assets/group_create_ad.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 7096ca408..617dc4330 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -13,6 +13,7 @@ ADD_USER = os.path.join(TEST_ASSET_DIR, 'group_add_user.xml') ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') +CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, 'group_create_ad.xml') CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') @@ -185,6 +186,30 @@ def test_create_group(self): self.assertEqual(group.name, u'試供品') self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + def test_create_ad_group(self): + with open(CREATE_GROUP_AD, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + group_to_create = TSC.GroupItem(u'試供品') + group_to_create.domain_name = 'just-has-to-exist' + group = self.server.groups.create_AD_group(group_to_create, False) + self.assertEqual(group.name, u'試供品') + self.assertEqual(group.license_mode, 'onLogin') + self.assertEqual(group.minimum_site_role, 'Creator') + self.assertEqual(group.domain_name, 'active-directory-domain-name') + + def test_create_group_async(self): + with open(CREATE_GROUP_ASYNC, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + group_to_create = TSC.GroupItem(u'試供品') + group_to_create.domain_name = 'woohoo' + job = self.server.groups.create_AD_group(group_to_create, True) + self.assertEqual(job.mode, 'Asynchronous') + self.assertEqual(job.type, 'GroupImport') + def test_update(self): with open(UPDATE_XML, 'rb') as f: response_xml = f.read().decode('utf-8') From 0ffc33eccfc7a97a6186d0c3714e047645a7a489 Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 22 Aug 2020 21:04:01 -0700 Subject: [PATCH 186/567] with another test --- tableauserverclient/models/group_item.py | 9 +++------ test/assets/group_create.xml | 4 +++- test/assets/group_update.xml | 4 +++- test/test_group.py | 2 ++ 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 77890227d..e928b0027 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -85,13 +85,10 @@ def from_response(cls, resp, ns): group_item.minimum_site_role = import_elem.get('siteRole') else: # local group, we will just have two extra attributes here - group_item._license_mode = group_xml.get('grantLicenseMode') - group_item._minimum_site_role = group_xml.get('siteRole') + group_item.domain_name = 'local' + group_item.license_mode = group_xml.get('grantLicenseMode') + group_item.minimum_site_role = group_xml.get('siteRole') - # what is this stuff? when would there be a domain element in a group? - domain_elem = group_xml.find('.//t:domain', namespaces=ns) - if domain_elem is not None: - group_item._domain_name = domain_elem.get('name', None) all_group_items.append(group_item) return all_group_items diff --git a/test/assets/group_create.xml b/test/assets/group_create.xml index 8fb3902a4..face05cf0 100644 --- a/test/assets/group_create.xml +++ b/test/assets/group_create.xml @@ -2,5 +2,7 @@ - + \ No newline at end of file diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml index b5dba4bc6..828e3f251 100644 --- a/test/assets/group_update.xml +++ b/test/assets/group_update.xml @@ -2,5 +2,7 @@ - + /> \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 617dc4330..8aeb4817d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -222,3 +222,5 @@ def test_update(self): self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) self.assertEqual('Group updated name', group.name) + self.assertEqual('ExplorerCanPublish', group.minimum_site_role) + self.assertEqual('onLogin', group.license_mode) From 6e3f96de6040bfbf3b148e7960b92b94a5d4d342 Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 22 Aug 2020 21:16:22 -0700 Subject: [PATCH 187/567] pycodestyle --- tableauserverclient/server/endpoint/groups_endpoint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 6b023e4dd..ac3ca2203 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -91,7 +91,6 @@ def create_AD_group(self, group_item, asJob=False): else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item, user_id): @@ -119,4 +118,4 @@ def add_user(self, group_item, user_id): server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) - return user \ No newline at end of file + return user From a90fcf2986759a239279512f5238495586a7e992 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 28 Aug 2020 21:36:24 -0700 Subject: [PATCH 188/567] tyler feedback --- tableauserverclient/models/group_item.py | 4 +++- .../server/endpoint/groups_endpoint.py | 4 ++-- tableauserverclient/server/request_factory.py | 12 ++++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index e928b0027..ba9beec27 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,7 +1,8 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_empty +from .property_decorators import property_not_empty, property_is_enum from .reference_item import ResourceReference +from .user_item import UserItem class GroupItem(object): @@ -51,6 +52,7 @@ def minimum_site_role(self): return self._minimum_site_role @minimum_site_role.setter + @property_is_enum(UserItem.Roles) def minimum_site_role(self, value): self._minimum_site_role = value diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ac3ca2203..c873dc159 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -58,7 +58,7 @@ def delete(self, group_id): logger.info('Deleted single group (ID: {0})'.format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=UNLICENSED_USER, asJob=False): + def update(self, group_item, default_site_role=UNLICENSED_USER, as_job=False): if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -66,7 +66,7 @@ def update(self, group_item, default_site_role=UNLICENSED_USER, asJob=False): update_req = RequestFactory.Group.update_req(group_item, default_site_role) server_response = self.put_request(url, update_req) logger.info('Updated group item (ID: {0})'.format(group_item.id)) - if (asJob): + if (as_job): return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f91982193..cd0c9444f 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -242,9 +242,9 @@ def create_local_req(self, group_item): xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name - if (group_item.license_mode is not None): + if group_item.license_mode is not None: group_element.attrib['grantLicenseMode'] = group_item.license_mode - if (group_item.minimum_site_role is not None): + if group_item.minimum_site_role is not None: group_element.attrib['SiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) @@ -254,19 +254,19 @@ def create_ad_req(self, group_item): group_element.attrib['name'] = group_item.name import_element = ET.SubElement(group_element, 'import') import_element.attrib['source'] = "ActiveDirectory" - if (group_item.domain_name is None): + if group_item.domain_name is None: error = "Group Domain undefined." raise ValueError(error) import_element.attrib['domainName'] = group_item.domain_name - if (group_item.license_mode is not None): + if group_item.license_mode is not None: import_element.attrib['grantLicenseMode'] = group_item.license - if (group_item.minimum_site_role is not None): + if group_item.minimum_site_role is not None: import_element.attrib['SiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) def update_req(self, group_item, default_site_role=None): - if (default_site_role is not None): + if default_site_role is not None: group_item.minimum_site_role = default_site_role xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') From 3846bd432194b9de260d156f970a94588ec7d97d Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 31 Aug 2020 13:47:22 -0700 Subject: [PATCH 189/567] Update setup.py (#650) We should support all the right things now for quoting. --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 58fa03311..5586e4716 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,6 @@ setup_requires=pytest_runner, install_requires=[ 'requests>=2.11,<3.0', - 'urllib3>=1.24.3,<2.0' ], tests_require=[ 'requests-mock>=1.0,<2.0', From 22298432f5e305a61cb3a40ca1c2c74e8ec84f38 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 1 Sep 2020 11:43:47 -0700 Subject: [PATCH 190/567] Updates changelog and contributors file for v0.13 (#684) * Added notes field to JobItem (#571) * Added webpage_url field to WorkbookItem (#661) * Added support for switching between sites (#655) * Added support for querying favorites for a user (#656) * Added support for Python 3.8 (#659) * Added support for Data Alerts (#667) * Added support for basic Extract operations - Create, Delete, en/re/decrypt for site (#672) * Added support for creating and querying Active Directory groups (#674) * Added support for asynchronously updating a group (#674) * Improved handling of invalid dates (#529) * Improved consistency of update_permission endpoints (#668) * Documentation updates (#658, #669, #670, #673, #683) --- CHANGELOG.md | 15 +++++++++++++-- CONTRIBUTORS.md | 3 +++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e86f37c..a0e8333e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ -## 0.13 (19 Aug 2020) -* Add support for basic Extract operations - Create, Delete, en/re/decrypt for site +## 0.13 (1 Sept 2020) +* Added notes field to JobItem (#571) +* Added webpage_url field to WorkbookItem (#661) +* Added support for switching between sites (#655) +* Added support for querying favorites for a user (#656) +* Added support for Python 3.8 (#659) +* Added support for Data Alerts (#667) +* Added support for basic Extract operations - Create, Delete, en/re/decrypt for site (#672) +* Added support for creating and querying Active Directory groups (#674) +* Added support for asynchronously updating a group (#674) +* Improved handling of invalid dates (#529) +* Improved consistency of update_permission endpoints (#668) +* Documentation updates (#658, #669, #670, #673, #683) ## 0.12.1 (22 July 2020) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e5c80d4ac..1f9714c6d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -37,6 +37,8 @@ The following people have contributed to this project to make it possible, and w * [Jordan Woods](https://github.com/jorwoods) * [Reba Magier](https://github.com/rmagier1) * [Stephen Mitchell](https://github.com/scuml) +* [absentmoose](https://github.com/absentmoose) +* [Paul Vickers](https://github.com/paulvic) ## Core Team @@ -49,3 +51,4 @@ The following people have contributed to this project to make it possible, and w * [Priya Reguraman](https://github.com/preguraman) * [Jac Fitzgerald](https://github.com/jacalata) * [Dan Zucker](https://github.com/dzucker-tab) +* [Brian Cantoni](https://github.com/bcantoni) From 57fedb9319b618b0312a0a56737a669f283fedb5 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Thu, 10 Sep 2020 10:55:41 -0700 Subject: [PATCH 191/567] Encode tag-name before deleting (#687) * Encode tag-name when deleting and improve error handling * Fix view tags to add/delete properly --- tableauserverclient/models/view_item.py | 3 ++- tableauserverclient/server/endpoint/resource_tagger.py | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 958cef7a2..f9b7a2940 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -2,6 +2,7 @@ from ..datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError from .tag_item import TagItem +import copy class ViewItem(object): @@ -158,7 +159,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id=''): if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags - view_item._initial_tags = tags + view_item._initial_tags = copy.copy(tags) all_view_items.append(view_item) return all_view_items diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index d9f925380..ccee9fa10 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -4,6 +4,7 @@ from ...models.tag_item import TagItem import logging import copy +import urllib.parse logger = logging.getLogger('tableau.endpoint.resource_tagger') @@ -18,21 +19,23 @@ def _add_tags(self, baseurl, resource_id, tag_set): server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) except ServerResponseError as e: - if e.code == "404003": + if e.code == "404008": error = "Adding tags to this resource type is only available with REST API version 2.6 and later." raise EndpointUnavailableError(error) raise # Some other error # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, tag_name) + encoded_tag_name = urllib.parse.quote(tag_name) + url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) try: self.delete_request(url) except ServerResponseError as e: - if e.code == "404003": + if e.code == "404008": error = "Deleting tags from this resource type is only available with REST API version 2.6 and later." raise EndpointUnavailableError(error) + raise # Some other error # Remove and add tags to match the resource item's tag set def update_tags(self, baseurl, resource_item): From 6301e999bde1c96abc0d07bbaa279290a2669888 Mon Sep 17 00:00:00 2001 From: jessicachen79 <67386373+jessicachen79@users.noreply.github.com> Date: Thu, 10 Sep 2020 11:59:44 -0700 Subject: [PATCH 192/567] Added endpoints for revoking all server admins tokens (#689) * Added endpoints for revoking all server admins tokens * NotSignedInError is thrown if user is not signed in * Removed isSignedIn check --- .../server/endpoint/auth_endpoint.py | 6 ++++++ test/test_auth.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index f74b88b21..1d6ca500d 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -73,3 +73,9 @@ def switch_site(self, site_item): self.parent_srv._set_auth(site_id, user_id, auth_token) logger.info('Signed into {0} as user with id {1}'.format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + + @api(version="3.10") + def revoke_all_server_admin_tokens(self): + url = "{0}/{1}".format(self.baseurl, 'revokeAllServerAdminTokens') + self.post_request(url, '') + logger.info('Revoked all tokens for all server admins') diff --git a/test/test_auth.py b/test/test_auth.py index b879ab121..3dbf87737 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -106,3 +106,19 @@ def test_switch_site(self): self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + + def test_revoke_all_server_admin_tokens(self): + self.server.version = "3.10" + baseurl = self.server.auth.baseurl + with open(SIGN_IN_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(baseurl + '/signin', text=response_xml) + m.post(baseurl + '/revokeAllServerAdminTokens', text='') + tableau_auth = TSC.TableauAuth('testuser', 'password') + self.server.auth.sign_in(tableau_auth) + self.server.auth.revoke_all_server_admin_tokens() + + self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) + self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) + self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) From 8d513558597a55d80795a05733839d2338be3744 Mon Sep 17 00:00:00 2001 From: Madhura Selvarajan Date: Wed, 23 Sep 2020 10:45:27 -0400 Subject: [PATCH 193/567] Update publish_workbook.py (#694) * Update publish_workbook.py Added below arguments, without this there is a sign-in error on publishing a test file to Tableau Online parser.add_argument('--sitename', '-S', default='', help='sitename required') tableau_auth = TSC.TableauAuth(args.username, password,site_id=args.sitename) * Update publish_workbook.py Edits (as requested) to publish workbooks on Tableau Online which removes the Sign-in Error. * Update publish_workbook.py --- samples/publish_workbook.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 927e9c3ad..be2c9599f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -27,10 +27,11 @@ def main(): parser = argparse.ArgumentParser(description='Publish a workbook to server.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') + parser.add_argument('--filepath', '-f', required=True, help='computer filepath of the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') + parser.add_argument('--site', '-S', default='', help='id (contentUrl) of site to sign into') args = parser.parse_args() @@ -41,7 +42,7 @@ def main(): logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) server = TSC.Server(args.server) overwrite_true = TSC.Server.PublishMode.Overwrite From 342c0c513fe5e11cc1df6cbf987ceff85d99416f Mon Sep 17 00:00:00 2001 From: elsherif Date: Tue, 29 Sep 2020 22:04:05 +0200 Subject: [PATCH 194/567] Add 'Execute' Capability (#700) * Update publish_workbook.py (#694) * Update publish_workbook.py Added below arguments, without this there is a sign-in error on publishing a test file to Tableau Online parser.add_argument('--sitename', '-S', default='', help='sitename required') tableau_auth = TSC.TableauAuth(args.username, password,site_id=args.sitename) * Update publish_workbook.py Edits (as requested) to publish workbooks on Tableau Online which removes the Sign-in Error. * Update publish_workbook.py * Add 'Execute' Capability This Capability was missing and is needed to be able to set permission "Run Flow" on prep flows. * sort capabilities alphabetically moved 'Execute' capability to line 23 to be sorted alphabetically. Co-authored-by: Chris Shin Co-authored-by: Madhura Selvarajan --- samples/publish_workbook.py | 5 +++-- tableauserverclient/models/permissions_item.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 927e9c3ad..be2c9599f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -27,10 +27,11 @@ def main(): parser = argparse.ArgumentParser(description='Publish a workbook to server.') parser.add_argument('--server', '-s', required=True, help='server address') parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--filepath', '-f', required=True, help='filepath to the workbook to publish') + parser.add_argument('--filepath', '-f', required=True, help='computer filepath of the workbook to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') + parser.add_argument('--site', '-S', default='', help='id (contentUrl) of site to sign into') args = parser.parse_args() @@ -41,7 +42,7 @@ def main(): logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) server = TSC.Server(args.server) overwrite_true = TSC.Server.PublishMode.Overwrite diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 40049e3c4..216315587 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -20,6 +20,7 @@ class Capability: ChangePermissions = 'ChangePermissions' Connect = 'Connect' Delete = 'Delete' + Execute = 'Execute' ExportData = 'ExportData' ExportImage = 'ExportImage' ExportXml = 'ExportXml' From c9d7c6a78f529c4f5e2021a8aa53607dad15d3b4 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 16 Oct 2020 10:04:24 -0700 Subject: [PATCH 195/567] Updates datasource_item with new fields (#705) * Adds new fields and updates parser * Adds new fields to publish and update requests * Adds tests and description field --- tableauserverclient/models/datasource_item.py | 140 +++++++++++++----- tableauserverclient/server/request_factory.py | 14 ++ test/assets/datasource_get.xml | 4 +- test/assets/datasource_get_by_id.xml | 3 +- test/test_datasource.py | 10 ++ 5 files changed, 131 insertions(+), 40 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e76a42aae..1d8bf49f2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,23 +1,34 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean +from .property_decorators import property_not_nullable, property_is_boolean, property_is_enum from .tag_item import TagItem from ..datetime_helpers import parse_datetime import copy class DatasourceItem(object): + class AskDataEnablement: + Enabled = 'Enabled' + Disabled = 'Disabled' + SiteDefault = 'SiteDefault' + def __init__(self, project_id, name=None): + self._ask_data_enablement = None + self._certified = None + self._certification_note = None self._connections = None self._content_url = None self._created_at = None self._datasource_type = None + self._encrypt_extracts = None + self._has_extracts = None self._id = None self._initial_tags = set() self._project_name = None self._updated_at = None - self._certified = None - self._certification_note = None + self._use_remote_query_agent = None + self._webpage_url = None + self.description = None self.name = name self.owner_id = None self.project_id = project_id @@ -25,6 +36,15 @@ def __init__(self, project_id, name=None): self._permissions = None + @property + def ask_data_enablement(self): + return self._ask_data_enablement + + @ask_data_enablement.setter + @property_is_enum(AskDataEnablement) + def ask_data_enablement(self, value): + self._ask_data_enablement = value + @property def connections(self): if self._connections is None: @@ -65,6 +85,15 @@ def certification_note(self): def certification_note(self, value): self._certification_note = value + @property + def encrypt_extracts(self): + return self._encrypt_extracts + + @encrypt_extracts.setter + @property_is_boolean + def encrypt_extracts(self, value): + self._encrypt_extracts = value + @property def id(self): return self._id @@ -90,6 +119,15 @@ def datasource_type(self): def updated_at(self): return self._updated_at + @property + def use_remote_query_agent(self): + return self._use_remote_query_agent + + @use_remote_query_agent.setter + @property_is_boolean + def use_remote_query_agent(self, value): + self._use_remote_query_agent = value + def _set_connections(self, connections): self._connections = connections @@ -100,38 +138,53 @@ def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): datasource_xml = ET.fromstring(datasource_xml).find('.//t:datasource', namespaces=ns) if datasource_xml is not None: - (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id, - certified, certification_note) = self._parse_element(datasource_xml, ns) - self._set_values(None, None, None, None, None, updated_at, None, project_id, - project_name, owner_id, certified, certification_note) + (ask_data_enablement, certified, certification_note, _, _, _, _, encrypt_extracts, has_extracts, + _, _, owner_id, project_id, project_name, _, updated_at, use_remote_query_agent, + webpage_url) = self._parse_element(datasource_xml, ns) + self._set_values(ask_data_enablement, certified, certification_note, None, None, None, None, + encrypt_extracts, has_extracts, None, None, owner_id, project_id, project_name, None, + updated_at, use_remote_query_agent, webpage_url) return self - def _set_values(self, id, name, datasource_type, content_url, created_at, - updated_at, tags, project_id, project_name, owner_id, certified, certification_note): - if id is not None: - self._id = id - if name: - self.name = name - if datasource_type: - self._datasource_type = datasource_type + def _set_values(self, ask_data_enablement, certified, certification_note, content_url, created_at, datasource_type, + description, encrypt_extracts, has_extracts, id_, name, owner_id, project_id, project_name, tags, + updated_at, use_remote_query_agent, webpage_url): + if ask_data_enablement is not None: + self._ask_data_enablement = ask_data_enablement + if certification_note: + self.certification_note = certification_note + self.certified = certified # Always True/False, not conditional if content_url: self._content_url = content_url if created_at: self._created_at = created_at - if updated_at: - self._updated_at = updated_at - if tags: - self.tags = tags - self._initial_tags = copy.copy(tags) + if datasource_type: + self._datasource_type = datasource_type + if description: + self.description = description + if encrypt_extracts is not None: + self.encrypt_extracts = str(encrypt_extracts).lower() == 'true' + if has_extracts is not None: + self._has_extracts = str(has_extracts).lower() == 'true' + if id_ is not None: + self._id = id_ + if name: + self.name = name + if owner_id: + self.owner_id = owner_id if project_id: self.project_id = project_id if project_name: self._project_name = project_name - if owner_id: - self.owner_id = owner_id - if certification_note: - self.certification_note = certification_note - self.certified = certified # Always True/False, not conditional + if tags: + self.tags = tags + self._initial_tags = copy.copy(tags) + if updated_at: + self._updated_at = updated_at + if use_remote_query_agent is not None: + self._use_remote_query_agent = str(use_remote_query_agent).lower() == 'true' + if webpage_url: + self._webpage_url = webpage_url @classmethod def from_response(cls, resp, ns): @@ -140,25 +193,32 @@ def from_response(cls, resp, ns): all_datasource_xml = parsed_response.findall('.//t:datasource', namespaces=ns) for datasource_xml in all_datasource_xml: - (id_, name, datasource_type, content_url, created_at, updated_at, - tags, project_id, project_name, owner_id, - certified, certification_note) = cls._parse_element(datasource_xml, ns) + (ask_data_enablement, certified, certification_note, content_url, created_at, datasource_type, + description, encrypt_extracts, has_extracts, id_, name, owner_id, project_id, project_name, tags, + updated_at, use_remote_query_agent, webpage_url) = cls._parse_element(datasource_xml, ns) datasource_item = cls(project_id) - datasource_item._set_values(id_, name, datasource_type, content_url, created_at, updated_at, - tags, None, project_name, owner_id, certified, certification_note) + datasource_item._set_values(ask_data_enablement, certified, certification_note, content_url, + created_at, datasource_type, description, encrypt_extracts, + has_extracts, id_, name, owner_id, None, project_name, tags, updated_at, + use_remote_query_agent, webpage_url) all_datasource_items.append(datasource_item) return all_datasource_items @staticmethod def _parse_element(datasource_xml, ns): - id_ = datasource_xml.get('id', None) - name = datasource_xml.get('name', None) - datasource_type = datasource_xml.get('type', None) + certification_note = datasource_xml.get('certificationNote', None) + certified = str(datasource_xml.get('isCertified', None)).lower() == 'true' content_url = datasource_xml.get('contentUrl', None) created_at = parse_datetime(datasource_xml.get('createdAt', None)) + datasource_type = datasource_xml.get('type', None) + description = datasource_xml.get('description', None) + encrypt_extracts = datasource_xml.get('encryptExtracts', None) + has_extracts = datasource_xml.get('hasExtracts', None) + id_ = datasource_xml.get('id', None) + name = datasource_xml.get('name', None) updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) - certification_note = datasource_xml.get('certificationNote', None) - certified = str(datasource_xml.get('isCertified', None)).lower() == 'true' + use_remote_query_agent = datasource_xml.get('useRemoteQueryAgent', None) + webpage_url = datasource_xml.get('webpageUrl', None) tags = None tags_elem = datasource_xml.find('.//t:tags', namespaces=ns) @@ -177,5 +237,11 @@ def _parse_element(datasource_xml, ns): if owner_elem is not None: owner_id = owner_elem.get('id', None) - return (id_, name, datasource_type, content_url, created_at, updated_at, tags, project_id, - project_name, owner_id, certified, certification_note) + ask_data_enablement = None + ask_data_elem = datasource_xml.find('.//t:askData', namespaces=ns) + if ask_data_elem is not None: + ask_data_enablement = ask_data_elem.get('enablement', None) + + return (ask_data_enablement, certified, certification_note, content_url, created_at, + datasource_type, description, encrypt_extracts, has_extracts, id_, name, owner_id, + project_id, project_name, tags, updated_at, use_remote_query_agent, webpage_url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7cd38189c..e40f42034 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -131,6 +131,15 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection xml_request = ET.Element('tsRequest') datasource_element = ET.SubElement(xml_request, 'datasource') datasource_element.attrib['name'] = datasource_item.name + if datasource_item.description: + datasource_element.attrib['description'] = str(datasource_item.description) + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib['useRemoteQueryAgent'] = str(datasource_item.use_remote_query_agent).lower() + + if datasource_item.ask_data_enablement: + ask_data_element = ET.SubElement(datasource_element, 'askData') + ask_data_element.attrib['enablement'] = datasource_item.ask_data_enablement + project_element = ET.SubElement(datasource_element, 'project') project_element.attrib['id'] = datasource_item.project_id @@ -149,6 +158,9 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection def update_req(self, datasource_item): xml_request = ET.Element('tsRequest') datasource_element = ET.SubElement(xml_request, 'datasource') + if datasource_item.ask_data_enablement: + ask_data_element = ET.SubElement(datasource_element, 'askData') + ask_data_element.attrib['enablement'] = datasource_item.ask_data_enablement if datasource_item.project_id: project_element = ET.SubElement(datasource_element, 'project') project_element.attrib['id'] = datasource_item.project_id @@ -160,6 +172,8 @@ def update_req(self, datasource_item): if datasource_item.certification_note: datasource_element.attrib['certificationNote'] = str(datasource_item.certification_note) + if datasource_item.encrypt_extracts is not None: + datasource_element.attrib['encryptExtracts'] = str(datasource_item.encrypt_extracts).lower() return ET.tostring(xml_request) diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml index c3ccfa0da..5858d318d 100644 --- a/test/assets/datasource_get.xml +++ b/test/assets/datasource_get.xml @@ -2,12 +2,12 @@ - + - + diff --git a/test/assets/datasource_get_by_id.xml b/test/assets/datasource_get_by_id.xml index 177899b15..d5dcf89ee 100644 --- a/test/assets/datasource_get_by_id.xml +++ b/test/assets/datasource_get_by_id.xml @@ -1,6 +1,6 @@ - + @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/test/test_datasource.py b/test/test_datasource.py index 7c6be6f67..1fc73b97a 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -47,6 +47,10 @@ def test_get(self): self.assertEqual('SampleDS', all_datasources[0].name) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[0].project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[0].owner_id) + self.assertEqual('https://web.com', all_datasources[0]._webpage_url) + self.assertFalse(all_datasources[0].encrypt_extracts) + self.assertTrue(all_datasources[0]._has_extracts) + self.assertFalse(all_datasources[0]._use_remote_query_agent) self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) @@ -58,6 +62,10 @@ def test_get(self): self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[1].project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[1].owner_id) self.assertEqual(set(['world', 'indicators', 'sample']), all_datasources[1].tags) + self.assertEqual('https://page.com', all_datasources[1]._webpage_url) + self.assertTrue(all_datasources[1].encrypt_extracts) + self.assertFalse(all_datasources[1]._has_extracts) + self.assertTrue(all_datasources[1]._use_remote_query_agent) def test_get_before_signin(self): self.server._auth_token = None @@ -88,6 +96,8 @@ def test_get_by_id(self): self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_datasource.project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_datasource.owner_id) self.assertEqual(set(['world', 'indicators', 'sample']), single_datasource.tags) + self.assertEqual("test-ds", single_datasource.description) + self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self): response_xml = read_xml_asset(UPDATE_XML) From 3eca26df1ffa5f365c074d60bdaee552464df295 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 21 Oct 2020 15:45:47 -0700 Subject: [PATCH 196/567] Adds missing getters for some of the new datasource fields (#708) --- tableauserverclient/models/datasource_item.py | 8 ++++++++ test/test_datasource.py | 12 ++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1d8bf49f2..a50d5a412 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -94,6 +94,10 @@ def encrypt_extracts(self): def encrypt_extracts(self, value): self._encrypt_extracts = value + @property + def has_extracts(self): + return self._has_extracts + @property def id(self): return self._id @@ -128,6 +132,10 @@ def use_remote_query_agent(self): def use_remote_query_agent(self, value): self._use_remote_query_agent = value + @property + def webpage_url(self): + return self._webpage_url + def _set_connections(self, connections): self._connections = connections diff --git a/test/test_datasource.py b/test/test_datasource.py index 1fc73b97a..7d3ca0d61 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -47,10 +47,10 @@ def test_get(self): self.assertEqual('SampleDS', all_datasources[0].name) self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[0].project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[0].owner_id) - self.assertEqual('https://web.com', all_datasources[0]._webpage_url) + self.assertEqual('https://web.com', all_datasources[0].webpage_url) self.assertFalse(all_datasources[0].encrypt_extracts) - self.assertTrue(all_datasources[0]._has_extracts) - self.assertFalse(all_datasources[0]._use_remote_query_agent) + self.assertTrue(all_datasources[0].has_extracts) + self.assertFalse(all_datasources[0].use_remote_query_agent) self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) self.assertEqual('dataengine', all_datasources[1].datasource_type) @@ -62,10 +62,10 @@ def test_get(self): self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[1].project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[1].owner_id) self.assertEqual(set(['world', 'indicators', 'sample']), all_datasources[1].tags) - self.assertEqual('https://page.com', all_datasources[1]._webpage_url) + self.assertEqual('https://page.com', all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) - self.assertFalse(all_datasources[1]._has_extracts) - self.assertTrue(all_datasources[1]._use_remote_query_agent) + self.assertFalse(all_datasources[1].has_extracts) + self.assertTrue(all_datasources[1].use_remote_query_agent) def test_get_before_signin(self): self.server._auth_token = None From 87b74562366c7af2255c5bdb6957d89e342640cd Mon Sep 17 00:00:00 2001 From: Stephen Mitchell Date: Thu, 29 Oct 2020 11:07:40 -0400 Subject: [PATCH 197/567] Django-style filters (#615) * delete docs folder from master (#520) * delete folder * add back readme for docs * Fix logger statement in User.add (#608) The logger statement is using the parameter to output the id of the user, but that isnt set until line 67 and saved to a new variable. We want the logger statement to use that new user * Adds django-style shorthand to filter, sort, and paginate * Consolidate code, add all() * Update query.py --- .gitignore | 4 + .../server/endpoint/datasources_endpoint.py | 5 +- .../server/endpoint/endpoint.py | 24 ++++- .../server/endpoint/users_endpoint.py | 4 +- .../server/endpoint/views_endpoint.py | 4 +- .../server/endpoint/workbooks_endpoint.py | 10 ++- tableauserverclient/server/query.py | 89 +++++++++++++++++++ test/test_request_option.py | 43 +++++++++ 8 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 tableauserverclient/server/query.py diff --git a/.gitignore b/.gitignore index 36c353401..5efc6b31d 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,10 @@ target/ # pyenv .python-version +# poetry +poetry.lock +pyproject.toml + # celery beat schedule file celerybeat-schedule diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 87afb4914..7916d7571 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,9 +1,10 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem +from ..query import QuerySet from ...filesys_helpers import to_filename, make_download_path from ...models.job_item import JobItem @@ -21,7 +22,7 @@ logger = logging.getLogger('tableau.endpoint.datasources') -class Datasources(Endpoint): +class Datasources(QuerysetEndpoint): def __init__(self, parent_srv): super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 5e48b5cc2..821fdada6 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,7 +1,7 @@ from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError from functools import wraps from xml.etree.ElementTree import ParseError - +from ..query import QuerySet import logging try: @@ -165,3 +165,25 @@ def wrapper(self, *args, **kwargs): return func(self, *args, **kwargs) return wrapper return _decorator + + +class QuerysetEndpoint(Endpoint): + @api(version="2.0") + def all(self, *args, **kwargs): + queryset = QuerySet(self) + return queryset + + @api(version="2.0") + def filter(self, *args, **kwargs): + queryset = QuerySet(self).filter(**kwargs) + return queryset + + @api(version="2.0") + def order_by(self, *args, **kwargs): + queryset = QuerySet(self).order_by(*args) + return queryset + + @api(version="2.0") + def paginate(self, **kwargs): + queryset = QuerySet(self).paginate(**kwargs) + return queryset diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 868287493..cd4ac64d4 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api +from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, UserItem, WorkbookItem, PaginationItem from ..pager import Pager @@ -9,7 +9,7 @@ logger = logging.getLogger('tableau.endpoint.users') -class Users(Endpoint): +class Users(QuerysetEndpoint): @property def baseurl(self): return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 7c8a4768e..cd2792f5d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api +from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint @@ -10,7 +10,7 @@ logger = logging.getLogger('tableau.endpoint.views') -class Views(Endpoint): +class Views(QuerysetEndpoint): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 2bde77dc9..836756cbb 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api, parameter_added_in +from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .fileuploads_endpoint import Fileuploads @@ -21,7 +21,7 @@ logger = logging.getLogger('tableau.endpoint.workbooks') -class Workbooks(Endpoint): +class Workbooks(QuerysetEndpoint): def __init__(self, parent_srv): super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -37,8 +37,10 @@ def get(self, req_options=None): logger.info('Querying all workbooks on site') url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - all_workbook_items = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response( + server_response.content, self.parent_srv.namespace) + all_workbook_items = WorkbookItem.from_response( + server_response.content, self.parent_srv.namespace) return all_workbook_items, pagination_item # Get 1 workbook diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py new file mode 100644 index 000000000..c8ba5e6c6 --- /dev/null +++ b/tableauserverclient/server/query.py @@ -0,0 +1,89 @@ +from .request_options import RequestOptions +from .filter import Filter +from .sort import Sort + + +def to_camel_case(word): + return word.split('_')[0] + ''.join(x.capitalize() or '_' for x in word.split('_')[1:]) + + +class QuerySet: + + def __init__(self, model): + self.model = model + self.request_options = RequestOptions() + self._result_cache = None + self._pagination_item = None + + def __iter__(self): + self._fetch_all() + return iter(self._result_cache) + + def __getitem__(self, k): + return list(self)[k] + + def _fetch_all(self): + """ + Retrieve the data and store result and pagination item in cache + """ + if self._result_cache is None: + self._result_cache, self._pagination_item = self.model.get(self.request_options) + + @property + def total_available(self): + self._fetch_all() + return self._pagination_item.total_available + + @property + def page_number(self): + self._fetch_all() + return self._pagination_item.page_number + + @property + def page_size(self): + self._fetch_all() + return self._pagination_item.page_size + + def filter(self, **kwargs): + for kwarg_key, value in kwargs.items(): + field_name, operator = self._parse_shorthand_filter(kwarg_key) + self.request_options.filter.add(Filter(field_name, operator, value)) + return self + + def order_by(self, *args): + for arg in args: + field_name, direction = self._parse_shorthand_sort(arg) + self.request_options.sort.add(Sort(field_name, direction)) + return self + + def paginate(self, **kwargs): + if "page_number" in kwargs: + self.request_options.pagenumber = kwargs["page_number"] + if "page_size" in kwargs: + self.request_options.pagesize = kwargs["page_size"] + return self + + def _parse_shorthand_filter(self, key): + tokens = key.split("__", 1) + if len(tokens) == 1: + operator = RequestOptions.Operator.Equals + else: + operator = tokens[1] + if operator not in RequestOptions.Operator.__dict__.values(): + raise ValueError("Operator `{}` is not valid.".format(operator)) + + field = to_camel_case(tokens[0]) + if field not in RequestOptions.Field.__dict__.values(): + raise ValueError("Field name `{}` is not valid.".format(field)) + return (field, operator) + + def _parse_shorthand_sort(self, key): + direction = RequestOptions.Direction.Asc + if key.startswith("-"): + direction = RequestOptions.Direction.Desc + key = key[1:] + + key = to_camel_case(key) + if key not in RequestOptions.Field.__dict__.values(): + raise ValueError("Sort key name %s is not valid.", key) + return (key, direction) diff --git a/test/test_request_option.py b/test/test_request_option.py index c5afcc3b2..e738a8eca 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -76,6 +76,17 @@ def test_filter_equals(self): self.assertEqual('RESTAPISample', matching_workbooks[0].name) self.assertEqual('RESTAPISample', matching_workbooks[1].name) + def test_filter_equals_shorthand(self): + with open(FILTER_EQUALS, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/workbooks?filter=name:eq:RESTAPISample', text=response_xml) + matching_workbooks = self.server.workbooks.filter(name='RESTAPISample').order_by("name") + + self.assertEqual(2, matching_workbooks.total_available) + self.assertEqual('RESTAPISample', matching_workbooks[0].name) + self.assertEqual('RESTAPISample', matching_workbooks[1].name) + def test_filter_tags_in(self): with open(FILTER_TAGS_IN, 'rb') as f: response_xml = f.read().decode('utf-8') @@ -91,6 +102,22 @@ def test_filter_tags_in(self): self.assertEqual(set(['safari']), matching_workbooks[1].tags) self.assertEqual(set(['sample']), matching_workbooks[2].tags) + def test_filter_tags_in_shorthand(self): + with open(FILTER_TAGS_IN, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/workbooks?filter=tags:in:[sample,safari,weather]', text=response_xml) + matching_workbooks = self.server.workbooks.filter(tags__in=['sample', 'safari', 'weather']) + + self.assertEqual(3, matching_workbooks.total_available) + self.assertEqual(set(['weather']), matching_workbooks[0].tags) + self.assertEqual(set(['safari']), matching_workbooks[1].tags) + self.assertEqual(set(['sample']), matching_workbooks[2].tags) + + def test_invalid_shorthand_option(self): + with self.assertRaises(ValueError): + self.server.workbooks.filter(nonexistant__in=['sample', 'safari']) + def test_multiple_filter_options(self): with open(FILTER_MULTIPLE, 'rb') as f: response_xml = f.read().decode('utf-8') @@ -107,3 +134,19 @@ def test_multiple_filter_options(self): for _ in range(100): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) + + def test_multiple_filter_options_shorthand(self): + with open(FILTER_MULTIPLE, 'rb') as f: + response_xml = f.read().decode('utf-8') + # To ensure that this is deterministic, run this a few times + with requests_mock.mock() as m: + # Sometimes pep8 requires you to do things you might not otherwise do + url = ''.join((self.baseurl, '/workbooks?pageNumber=1&pageSize=100&', + 'filter=name:eq:foo,tags:in:[sample,safari,weather]')) + m.get(url, text=response_xml) + + for _ in range(100): + matching_workbooks = self.server.workbooks.filter( + tags__in=['sample', 'safari', 'weather'], name='foo' + ) + self.assertEqual(3, matching_workbooks.total_available) From 6d52669ff2a662a1a1c4fd64ccccdb346896cef5 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 30 Oct 2020 11:27:11 -0700 Subject: [PATCH 198/567] Removes constraint that required interval when updating schedule (#711) * Removes constraint that required interval when updating schedule * Updates schedule tests --- .../server/endpoint/schedules_endpoint.py | 3 --- tableauserverclient/server/request_factory.py | 26 +++++++++--------- test/assets/schedule_update.xml | 2 +- test/test_schedule.py | 27 +++++++++++++++++++ 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 29389c693..3fd164b49 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -43,9 +43,6 @@ def update(self, schedule_item): if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - if schedule_item.interval_item is None: - error = "Interval item must be defined." - raise MissingRequiredFieldError(error) url = "{0}/{1}".format(self.baseurl, schedule_item.id) update_req = RequestFactory.Schedule.update_req(schedule_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e40f42034..b5dcf9912 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -398,19 +398,21 @@ def update_req(self, schedule_item): schedule_element.attrib['executionOrder'] = schedule_item.execution_order if schedule_item.state: schedule_element.attrib['state'] = schedule_item.state + interval_item = schedule_item.interval_item - if interval_item._frequency: - schedule_element.attrib['frequency'] = interval_item._frequency - frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') - frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: - frequency_element.attrib['end'] = str(interval_item.end_time) - intervals_element = ET.SubElement(frequency_element, 'intervals') - if hasattr(interval_item, 'interval'): - for interval in interval_item._interval_type_pairs(): - (expression, value) = interval - single_interval_element = ET.SubElement(intervals_element, 'interval') - single_interval_element.attrib[expression] = value + if interval_item is not None: + if interval_item._frequency: + schedule_element.attrib['frequency'] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') + frequency_element.attrib['start'] = str(interval_item.start_time) + if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: + frequency_element.attrib['end'] = str(interval_item.end_time) + intervals_element = ET.SubElement(frequency_element, 'intervals') + if hasattr(interval_item, 'interval'): + for interval in interval_item._interval_type_pairs(): + (expression, value) = interval + single_interval_element = ET.SubElement(intervals_element, 'interval') + single_interval_element.attrib[expression] = value return ET.tostring(xml_request) def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): diff --git a/test/assets/schedule_update.xml b/test/assets/schedule_update.xml index 314925377..7b814fdbc 100644 --- a/test/assets/schedule_update.xml +++ b/test/assets/schedule_update.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_schedule.py b/test/test_schedule.py index b7b047d02..3a84caeb9 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -192,6 +192,7 @@ def test_update(self): single_schedule = TSC.ScheduleItem("weekly-schedule-1", 90, TSC.ScheduleItem.Type.Extract, TSC.ScheduleItem.ExecutionOrder.Parallel, new_interval) single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" + single_schedule.state = TSC.ScheduleItem.State.Suspended single_schedule = self.server.schedules.update(single_schedule) self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) @@ -204,6 +205,32 @@ def test_update(self): self.assertEqual(time(7), single_schedule.interval_item.start_time) self.assertEqual(("Monday", "Friday"), single_schedule.interval_item.interval) + self.assertEqual(TSC.ScheduleItem.State.Suspended, single_schedule.state) + + # Tests calling update with a schedule item returned from the server + def test_update_after_get(self): + with open(GET_XML, "rb") as f: + get_response_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_response_xml = f.read().decode("utf-8") + + # Get a schedule + with requests_mock.mock() as m: + m.get(self.baseurl, text=get_response_xml) + all_schedules, pagination_item = self.server.schedules.get() + schedule_item = all_schedules[0] + self.assertEqual(TSC.ScheduleItem.State.Active, schedule_item.state) + self.assertEqual('Weekday early mornings', schedule_item.name) + + # Update the schedule + with requests_mock.mock() as m: + m.put(self.baseurl + '/c9cff7f9-309c-4361-99ff-d4ba8c9f5467', text=update_response_xml) + schedule_item.state = TSC.ScheduleItem.State.Suspended + schedule_item.name = 'newName' + schedule_item = self.server.schedules.update(schedule_item) + + self.assertEqual(TSC.ScheduleItem.State.Suspended, schedule_item.state) + self.assertEqual('weekly-schedule-1', schedule_item.name) def test_add_workbook(self): self.server.version = "2.8" From 1f4e27fe84fe9e978df1594d9352db19a72771e3 Mon Sep 17 00:00:00 2001 From: Niklas Nevalainen Date: Fri, 30 Oct 2020 20:55:58 +0200 Subject: [PATCH 199/567] File objects for publish workbook (#704) Contributed by @nnevalainen, take a file path or a file object for workbook publish --- tableauserverclient/filesys_helpers.py | 20 + .../server/endpoint/fileuploads_endpoint.py | 26 +- .../server/endpoint/workbooks_endpoint.py | 67 +- .../Data/Tableau Samples/World Indicators.tde | Bin 0 -> 151386 bytes test/assets/RESTAPISample.twb | 3573 +++++++++++++++++ test/test_workbook.py | 78 + 6 files changed, 3731 insertions(+), 33 deletions(-) create mode 100644 test/assets/Data/Tableau Samples/World Indicators.tde create mode 100644 test/assets/RESTAPISample.twb diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py index 9d0b443bf..3d6417464 100644 --- a/tableauserverclient/filesys_helpers.py +++ b/tableauserverclient/filesys_helpers.py @@ -20,3 +20,23 @@ def make_download_path(filepath, filename): download_path = filepath + os.path.splitext(filename)[1] return download_path + + +def get_file_object_size(file): + # Returns the size of a file object + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) + return file_size + + +def file_is_compressed(file): + # Determine if file is a zip file or not + # This reference lists magic file signatures: https://www.garykessler.net/library/file_sigs.html + + zip_file_signature = b'PK\x03\x04' + + is_zip_file = file.read(len(zip_file_signature)) == zip_file_signature + file.seek(0) + + return is_zip_file diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 088883f30..62224c894 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -39,23 +39,27 @@ def append(self, xml_request, content_type): logger.info('Uploading a chunk to session (ID: {0})'.format(self.upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) - def read_chunks(self, file_path): - with open(file_path, 'rb') as f: - while True: - chunked_content = f.read(CHUNK_SIZE) - if not chunked_content: - break - yield chunked_content + def read_chunks(self, file): + + while True: + chunked_content = file.read(CHUNK_SIZE) + if not chunked_content: + break + yield chunked_content @classmethod - def upload_chunks(cls, parent_srv, file_path): + def upload_chunks(cls, parent_srv, file): file_uploader = cls(parent_srv) upload_id = file_uploader.initiate() - chunks = file_uploader.read_chunks(file_path) + + try: + with open(file, 'rb') as f: + chunks = file_uploader.read_chunks(f) + except TypeError: + chunks = file_uploader.read_chunks(file) for chunk in chunks: xml_request, content_type = RequestFactory.Fileupload.chunk_req(chunk) fileupload_item = file_uploader.append(xml_request, content_type) - logger.info("\tPublished {0}MB of {1}".format(fileupload_item.file_size, - os.path.basename(file_path))) + logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) logger.info("\tCommitting file upload...") return upload_id diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 836756cbb..c59ae9cce 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -5,7 +5,7 @@ from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.job_item import JobItem -from ...filesys_helpers import to_filename, make_download_path +from ...filesys_helpers import to_filename, make_download_path, file_is_compressed, get_file_object_size import os import logging @@ -254,7 +254,7 @@ def delete_permission(self, item, capability_item): @parameter_added_in(as_job='3.0') @parameter_added_in(connections='2.8') def publish( - self, workbook_item, file_path, mode, + self, workbook_item, file, mode, connection_credentials=None, connections=None, as_job=False, hidden_views=None ): @@ -264,23 +264,40 @@ def publish( warnings.warn("connection_credentials is being deprecated. Use connections instead", DeprecationWarning) - if not os.path.isfile(file_path): - error = "File path does not lead to an existing file." - raise IOError(error) + try: + # Expect file to be a filepath + if not os.path.isfile(file): + error = "File path does not lead to an existing file." + raise IOError(error) + + filename = os.path.basename(file) + file_extension = os.path.splitext(filename)[1][1:] + file_size = os.path.getsize(file) + + # If name is not defined, grab the name from the file to publish + if not workbook_item.name: + workbook_item.name = os.path.splitext(filename)[0] + if file_extension not in ALLOWED_FILE_EXTENSIONS: + error = "Only {} files can be published as workbooks.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) + raise ValueError(error) + + except TypeError: + # Expect file to be a file object + file_size = get_file_object_size(file) + file_extension = 'twbx' if file_is_compressed(file) else 'twb' + + if not workbook_item.name: + error = "Workbook item must have a name when passing a file object" + raise ValueError(error) + + # Generate filename for file object. + # This is needed when publishing the workbook in a single request + filename = "{}.{}".format(workbook_item.name, file_extension) + if not hasattr(self.parent_srv.PublishMode, mode): error = 'Invalid mode defined.' raise ValueError(error) - filename = os.path.basename(file_path) - file_extension = os.path.splitext(filename)[1][1:] - - # If name is not defined, grab the name from the file to publish - if not workbook_item.name: - workbook_item.name = os.path.splitext(filename)[0] - if file_extension not in ALLOWED_FILE_EXTENSIONS: - error = "Only {} files can be published as workbooks.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) - raise ValueError(error) - # Construct the url with the defined mode url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: @@ -293,9 +310,9 @@ def publish( url += '&{0}=true'.format('asJob') # Determine if chunking is required (64MB is the limit for single upload method) - if os.path.getsize(file_path) >= FILESIZE_LIMIT: - logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename)) - upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) + if file_size >= FILESIZE_LIMIT: + logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(workbook_item.name)) + upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, @@ -304,8 +321,14 @@ def publish( hidden_views=hidden_views) else: logger.info('Publishing {0} to server'.format(filename)) - with open(file_path, 'rb') as f: - file_contents = f.read() + + try: + with open(file, 'rb') as f: + file_contents = f.read() + + except TypeError: + file_contents = file.read() + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item, filename, @@ -325,9 +348,9 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + logger.info('Published {0} (JOB_ID: {1}'.format(workbook_item.name, new_job.id)) return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id)) + logger.info('Published {0} (ID: {1})'.format(workbook_item.name, new_workbook.id)) return new_workbook diff --git a/test/assets/Data/Tableau Samples/World Indicators.tde b/test/assets/Data/Tableau Samples/World Indicators.tde new file mode 100644 index 0000000000000000000000000000000000000000..72162829bbc7373787d782323e29965e02329b80 GIT binary patch literal 151386 zcmdqJcU%-%6E=D#2ZEy*Fpm+8ASTQMNKjA_P%$&W2&2G^1O+iP<}3yj^O!SY&N}9- zm=MD%=B${rbL;7W)%D%o-@W(uegAB~Z*R>xefpeJ^;A7o-Oa9AC4{q)JrU6nR}u)%+}sbu5$u zUSc+jg;~X)|HOD>;kbc+GhUP!negXe`hoGL!g0(0W<2|C0gTTn9Cz_=#yd+GYuP3_ zHbz~jTPvpq^Nc&F%9fGZL~Vj0DLz7{?q`UP)g~tD zBJ0IzV|A^ZbjcCAnEH(xG-~Enzflv{2CmJTy3x_ZNv)5I(Z|t<#P}qg)6Zqx>o;&x z)538H&65&z@vWSq6BCWiU0ena9$Y_B8y=(6CM6j9B@Wic>*_}sVqIvc3kEv1R;tx4 zBMfnIx`;%AMja8OO-N|vguUtFqG%~6b!518Q{4>lF_G$aagq86ZK5GQp?+c{T}_CH z*2QXDIe8~1#%m)Ioz(nt);o&&um~*^uZz+0i~RMf^^R6fJqw5TuK(vRnk~f$ZJ~mb z+NE%!2yIM6l69^GYZsLG*{O~aSW(UcvNV#9UuYC}JDkS@XyADPhGi8nUgP1yXL z(dC!B`6*kv{4`3TjWDGehOzD`Au*mHby8cE>FH%iic5@7QM=GeQDjM4DBci5dymw| z>f+$`WGk{%T%tarp7l!6J*03)f}QTL&Kzfm)yHXLtk}>#u<+mSAznwr{@hc&h@^x> zx_5{!vdBRSH|N(}?MFisqt&`(quRNK+P7_Btv`1dtJ5YV#gl!l%MVP#CP_{GAl?71 zJ&?U=9}!9M@#^aTr#7OGOVmY?OBW&Xci3AE|8EMb5f;t0Nr{GfMP3w_6dSLL;7#$n zpIM`&Fv9-Ewctj7!WW}WF(f6b$sy{IE9&4rt(>$m#%OK0j=X|^q$@@p5kg#|Hi}%w zwSHqKb>U|J;g+JSf4!{{`C7DLa6R(Tq=-bqw#Xm&2x|GK9}d#SBGD5|v~>B&c&&-ut^CWZgwE

i(V=3NKDeT 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 + + + + + + + + + + + + + + + + + + + + + + + + Country / Region + 129 + [Country / Region] + [Extract] + Country / Region + 0 + DATA$ + string + Count + 209 + 1 + 1073741823 + false + + + "Afghanistan" + "Zimbabwe" + + + "en_US_CI" + true + "heap" + true + 4294967292 + 7 + "asc" + 2 + "str" + + + + Date + 135 + [Date] + [Extract] + Date + 1 + DATA$ + datetime + Year + 11 + false + + #2000-07-01 00:00:00# + #2010-07-01 00:00:00# + + + true + "array" + true + "asc" + 8 + 0 + "asc" + 1 + "datetime" + + + + F: Deposit interest rate (%) + 4 + [F: Deposit interest rate (%)] + [Extract] + F: Deposit interest rate (%) + 2 + DATA$ + real + Sum + 50 + true + + 0.0 + 203.0 + + + true + "array" + true + 4 + 5 + "asc" + 1 + "float" + + + + F: GDP (curr $) + 5 + [F: GDP (curr $)] + [Extract] + F: GDP (curr $) + 3 + DATA$ + real + Sum + 2120 + true + + 63810762.0 + 14447100000000.0 + + + 8 + 10 + "asc" + "double" + + + + F: GDP per capita (curr $) + 4 + [F: GDP per capita (curr $)] + [Extract] + F: GDP per capita (curr $) + 4 + DATA$ + real + Sum + 1877 + true + + 87.0 + 186243.0 + + + 4 + 9 + "asc" + "float" + + + + F: Lending interest rate (%) + 4 + [F: Lending interest rate (%)] + [Extract] + F: Lending interest rate (%) + 5 + DATA$ + real + Sum + 72 + true + + 1.0 + 496.0 + + + true + "array" + true + 4 + 6 + "asc" + 1 + "float" + + + + H: Health exp (% GDP) + 4 + [H: Health exp (% GDP)] + [Extract] + H: Health exp (% GDP) + 6 + DATA$ + real + Sum + 22 + true + + 0.0 + 20.0 + + + true + "array" + true + 4 + 3 + "asc" + 1 + "float" + + + + H: Health exp/cap (curr $) + 4 + [H: Health exp/cap (curr $)] + [Extract] + H: Health exp/cap (curr $) + 7 + DATA$ + real + Sum + 936 + true + + 3.0 + 8362.0 + + + true + "array" + true + 4 + 8 + "asc" + 2 + "float" + + + + H: Life exp (years) + 4 + [H: Life exp (years)] + [Extract] + H: Life exp (years) + 8 + DATA$ + real + Sum + 45 + true + + 40.0 + 83.0 + + + true + "array" + true + 4 + 4 + "asc" + 1 + "float" + + + + Number of Records + 16 + [Number of Records] + [Extract] + Number of Records + 9 + integer + Sum + 1 + false + + 1 + 1 + + + "asc" + 1 + "sint8" + + + + P: Population (count) + 5 + [P: Population (count)] + [Extract] + P: Population (count) + 10 + DATA$ + real + Sum + 2295 + false + + 18873.0 + 1337825000.0 + + + 8 + 11 + "asc" + "double" + + + + Region + 129 + [Region] + [Extract] + Region + 11 + DATA$ + string + Count + 6 + 1 + 1073741823 + false + + + "Africa" + "The Americas" + + + "en_US_CI" + true + "heap" + true + 4294967292 + 1 + "asc" + 1 + "str" + + + + Subregion + 129 + [Subregion] + [Extract] + Subregion + 12 + DATA$ + string + Count + 12 + 1 + 1073741823 + true + + + "Caribbean" + "Western Africa" + + + "en_US_CI" + true + "heap" + true + 4294967292 + 2 + "asc" + 1 + "str" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gross Domestic Product + in current US Dollars + + + + + + + Gross Domestic Product + per capita + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [Region] + [Subregion] + [Country / Region] + + + + + + + + + + + + + + + + + + + + + + + + + + + + "Europe" + "Middle East" + "The Americas" + "Oceania" + "Asia" + "Africa" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <formatted-text> + <run fontsize='12'>Country ranks by GDP, GDP per Capita, Population, and Life Expectancy</run> + </formatted-text> + + + + + + + + + + + + + + Gross Domestic Product + in current US Dollars + + + + + + + Gross Domestic Product + per capita + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "[World Indicators new].[sum:F: GDP (curr $):qk]" + "[World Indicators new].[rank:sum:F: GDP (curr $):qk]" + "[World Indicators new].[sum:F: GDP per capita (curr $):qk]" + "[World Indicators new].[rank:sum:F: GDP per capita (curr $):qk]" + "[World Indicators new].[sum:P: Population (count):qk]" + "[World Indicators new].[rank:sum:P: Population (count):qk]" + "[World Indicators new].[avg:H: Life exp (years):qk]" + "[World Indicators new].[rank:avg:H: Life exp (years) (copy):qk]" + + + + + + + + + [World Indicators new].[:Measure Names] + [World Indicators new].[yr:Date:ok] + [World Indicators new].[none:F: GDP (curr $):qk] + + + + + + + + + + + + + + + + <[World Indicators new].[none:Country / Region:nk]> + Æ + <[World Indicators new].[:Measure Names]>: + <[World Indicators new].[Multiple Values]> + + + + + + [World Indicators new].[none:Country / Region:nk] + [World Indicators new].[:Measure Names] +
+
+ + + + <formatted-text> + <run fontsize='11'><</run> + <run fontsize='11'>[World Indicators new].[yr:Date:ok]</run> + <run fontsize='11'>></run> + <run fontsize='11'> GDP per capita by country</run> + </formatted-text> + + + + + + + + + + + + + + + + + + + Gross Domestic Product + per capita + + + + + + + + + + + + + + + + + + + + + + [World Indicators new].[yr:Date:ok] + [World Indicators new].[none:Region:nk] + + + + + + + + + + + + + + + + + + + Country: + <[World Indicators new].[none:Country / Region:nk]> + Region: + <[World Indicators new].[none:Region:nk]> + GDP per capita (curr $): + <[World Indicators new].[avg:F: GDP per capita (curr $):qk]> + % of world average: + <[World Indicators new].[usr:Calculation1:qk]> + + + + + + [World Indicators new].[none:Country / Region:nk] + [World Indicators new].[avg:F: GDP per capita (curr $):qk] +
+
+ + + + <formatted-text> + <run fontsize='12'>GDP per capita by region </run> + <run>Click on a point to filter the map to a specific year.</run> + </formatted-text> + + + + + + + + + + + + + + + + + Gross Domestic Product + in current US Dollars + + + + + + + + + + + + + + + + + + [World Indicators new].[Action (Country Name)] + [World Indicators new].[Action (Region)] + + + + + + + + + + + + + + + + + <[World Indicators new].[none:Region:nk]> + Year: + <[World Indicators new].[yr:Date:ok]> + Average GDP (curr $): + <[World Indicators new].[avg:F: GDP (curr $):qk]> + GDP per capita (weighted): + <[World Indicators new].[usr:Calculation_1590906174513693:qk]> + + + + + + [World Indicators new].[usr:Calculation_1590906174513693:qk] + [World Indicators new].[yr:Date:ok] +
+
+ + + + <formatted-text> + <run fontsize='12'>GDP per capita by country </run> + <run>Currently filtered to </run> + <run fontcolor='#4f6e8d'><[World Indicators new].[yr:Date:ok]></run> + </formatted-text> + + + + + + + + + + + + + + + + + + + + + + Gross Domestic Product + per capita + + + + + + + + + + + + + + + + + + + + + + + + + + + + 199.0 + 104512.0 + + + + + + + + "The Americas" + "Europe" + %null% + "Oceania" + "Africa" + "Middle East" + "Asia" + %all% + + + + [World Indicators new].[avg:F: GDP per capita (curr $):qk] + [World Indicators new].[none:Region:nk] + [World Indicators new].[Action (YEAR(Date (year)))] + + + + + + + + + + + + + + + + + + + + + <[World Indicators new].[none:Country / Region:nk]> + Æ + Region: + <[World Indicators new].[none:Region:nk]> + Subregion: + <[World Indicators new].[none:Subregion:nk]> + GDP per capita (curr $): + <[World Indicators new].[avg:F: GDP per capita (curr $):qk]> + GDP % of Subregion average: + <[World Indicators new].[usr:Calculation1:qk:5]> + GDP % of World average: + <[World Indicators new].[usr:Calculation1:qk:1]> + + + + + + [World Indicators new].[Latitude (generated)] + [World Indicators new].[Longitude (generated)] +
+
+ + + + <formatted-text> + <run fontsize='12'><Sheet Name>, <Page Name></run> + <run>Æ </run> + <run fontcolor='#898989' fontsize='10'>Click the forward button on year to watch the change over time Hover over mark to see the history of that country</run> + </formatted-text> + + + + + + + + + + + + + + + + + + + + + + + [World Indicators new].[avg:H: Health exp/cap (curr $):qk] + [World Indicators new].[avg:H: Life exp (years):qk] + + + + + + + + + + + + + + + + + <[World Indicators new].[none:Country / Region:nk]> + Æ + Region: + <[World Indicators new].[none:Region:nk]> + Year: + <[World Indicators new].[yr:Date:ok]> + Health exp/cap (curr $): + <[World Indicators new].[avg:H: Health exp/cap (curr $):qk]> + Life Expectancy: + <[World Indicators new].[avg:H: Life exp (years):qk]> + + + + + + [World Indicators new].[avg:H: Life exp (years):qk] + [World Indicators new].[avg:H: Health exp/cap (curr $):qk] + + [World Indicators new].[yr:Date:ok] + + +
+
+ + + + <formatted-text> + <run fontsize='12'>Lending and deposit interest rates, GDP per capita and % of world GDP sorted by GDP per Capita for region and subregion, </run> + <run fontsize='12'><</run> + <run fontsize='12'>[World Indicators new].[yr:Date:ok]</run> + <run fontsize='12'>></run> + </formatted-text> + + + + + + + + + + + + + + + + + + + + Gross Domestic Product + in current US Dollars + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "[World Indicators new].[avg:F: Lending interest rate (\%):qk]" + "[World Indicators new].[avg:F: Deposit interest rate (\%):qk]" + "[World Indicators new].[usr:Calculation_8570907072742130:qk]" + "[World Indicators new].[usr:Calculation_1590906174513693:qk]" + "[World Indicators new].[pcto:sum:F: GDP (curr $):qk]" + + + + + + + + + [World Indicators new].[:Measure Names] + [World Indicators new].[yr:Date:ok] + + + + + + + + + + + + + + + + + ([World Indicators new].[none:Region:nk] / [World Indicators new].[none:Subregion:nk]) + [World Indicators new].[:Measure Names] +
+
+ + + + <formatted-text> + <run><[World Indicators new].[yr:Date:ok]> Country <Sheet Name></run> + </formatted-text> + + + + + + + + + + + + + + Gross Domestic Product + in current US Dollars + + + + + + + + + + + + + + + + + + + + [World Indicators new].[yr:Date:ok] + [World Indicators new].[sum:F: GDP (curr $):qk] + + + + + + + + + + + + + + + + + + + + <[World Indicators new].[none:Country / Region:nk]> + Æ + Region: + <[World Indicators new].[none:Region:nk]> + % of World GDP: + <[World Indicators new].[pcto:sum:F: GDP (curr $):qk:1]> + GDP (US $'s): + <[World Indicators new].[sum:F: GDP (curr $):qk]> + + + + + <[World Indicators new].[none:Country / Region:nk]> + Æ + <[World Indicators new].[pcto:sum:F: GDP (curr $):qk:1]> <[World Indicators new].[sum:F: GDP (curr $):qk]> + + + + + + + +
+
+
+ + + + + <formatted-text> + <run fontalignment='0'>GDP per Capita</run> + </formatted-text> + + + + + + + + + + "Europe" + "Middle East" + "The Americas" + "Oceania" + "Asia" + "Africa" + + + + <_.fcp.ObjectModelEncapsulateLegacy.true...object-graph> + + + + + + + + + diff --git a/test/assets/World Indicators.tdsx b/test/assets/World Indicators.tdsx new file mode 100644 index 0000000000000000000000000000000000000000..6e041442b38f1116689fe35079f5eb1b9bfcfadf GIT binary patch literal 64084 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 EA33e~>Hq)$ literal 0 HcmV?d00001 diff --git a/test/test_datasource.py b/test/test_datasource.py index 7d3ca0d61..1156069be 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,7 +1,10 @@ import unittest +from io import BytesIO import os import requests_mock import xml.etree.ElementTree as ET +from zipfile import ZipFile + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError @@ -240,6 +243,56 @@ def test_publish(self): self.assertEqual('default', new_datasource.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + def test_publish_a_non_packaged_file_object(self): + response_xml = read_xml_asset(PUBLISH_XML) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + + with open(asset('SampleDS.tds'), 'rb') as file_object: + new_datasource = self.server.datasources.publish(new_datasource, + file_object, + mode=publish_mode) + + self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) + self.assertEqual('SampleDS', new_datasource.name) + self.assertEqual('SampleDS', new_datasource.content_url) + self.assertEqual('dataengine', new_datasource.datasource_type) + self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) + self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) + self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) + self.assertEqual('default', new_datasource.project_name) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) + + def test_publish_a_packaged_file_object(self): + response_xml = read_xml_asset(PUBLISH_XML) + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + publish_mode = self.server.PublishMode.CreateNew + + # Create a dummy tdsx file in memory + with BytesIO() as zip_archive: + with ZipFile(zip_archive, 'w') as zf: + zf.write(asset('SampleDS.tds')) + + zip_archive.seek(0) + + new_datasource = self.server.datasources.publish(new_datasource, + zip_archive, + mode=publish_mode) + + self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) + self.assertEqual('SampleDS', new_datasource.name) + self.assertEqual('SampleDS', new_datasource.content_url) + self.assertEqual('dataengine', new_datasource.datasource_type) + self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) + self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) + self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) + self.assertEqual('default', new_datasource.project_name) + 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 @@ -260,6 +313,15 @@ def test_publish_async(self): self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) self.assertEqual('1', new_job.finish_code) + def test_publish_unnamed_file_object(self): + new_datasource = TSC.DatasourceItem('test') + publish_mode = self.server.PublishMode.CreateNew + + with open(asset('SampleDS.tds'), 'rb') as file_object: + self.assertRaises(ValueError, self.server.datasources.publish, + new_datasource, file_object, publish_mode + ) + def test_refresh_id(self): self.server.version = '2.8' self.baseurl = self.server.datasources.baseurl @@ -336,6 +398,29 @@ def test_publish_invalid_file_type(self): self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleWB.twbx'), self.server.PublishMode.Append) + def test_publish_hyper_file_object_raises_exception(self): + new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + with open(asset('World Indicators.hyper')) as file_object: + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, + file_object, self.server.PublishMode.Append) + + def test_publish_tde_file_object_raises_exception(self): + + new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + tds_asset = asset(os.path.join('Data', 'Tableau Samples', 'World Indicators.tde')) + with open(tds_asset) as file_object: + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, + file_object, self.server.PublishMode.Append) + + def test_publish_file_object_of_unknown_type_raises_exception(self): + new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + with BytesIO() as file_object: + file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.seek(0) + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, + file_object, self.server.PublishMode.Append) + def test_publish_multi_connection(self): new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') connection1 = TSC.ConnectionItem() diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py new file mode 100644 index 000000000..82fce8476 --- /dev/null +++ b/test/test_filesys_helpers.py @@ -0,0 +1,107 @@ +import unittest +from io import BytesIO +import os +from xml.etree import ElementTree as ET +from zipfile import ZipFile + +from tableauserverclient.filesys_helpers import get_file_object_size, get_file_type +from ._utils import asset, TEST_ASSET_DIR + + +class FilesysTests(unittest.TestCase): + + def test_get_file_size_returns_correct_size(self): + + target_size = 1000 # bytes + + with BytesIO() as f: + f.seek(target_size - 1) + f.write(b"\0") + file_size = get_file_object_size(f) + + self.assertEqual(file_size, target_size) + + def test_get_file_size_returns_zero_for_empty_file(self): + + with BytesIO() as f: + file_size = get_file_object_size(f) + + self.assertEqual(file_size, 0) + + def test_get_file_size_coincides_with_built_in_method(self): + + asset_path = asset('SampleWB.twbx') + target_size = os.path.getsize(asset_path) + with open(asset_path, 'rb') as f: + file_size = get_file_object_size(f) + + self.assertEqual(file_size, target_size) + + def test_get_file_type_identifies_a_zip_file(self): + + with BytesIO() as file_object: + with ZipFile(file_object, 'w') as zf: + with BytesIO() as stream: + stream.write('This is a zip file'.encode()) + zf.writestr('dummy_file', stream.getbuffer()) + file_object.seek(0) + file_type = get_file_type(file_object) + + self.assertEqual(file_type, 'zip') + + def test_get_file_type_identifies_tdsx_as_zip_file(self): + with open(asset('World Indicators.tdsx'), 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'zip') + + def test_get_file_type_identifies_twbx_as_zip_file(self): + with open(asset('SampleWB.twbx'), 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'zip') + + def test_get_file_type_identifies_xml_file(self): + + root = ET.Element('root') + child = ET.SubElement(root, 'child') + child.text = "This is a child element" + etree = ET.ElementTree(root) + + with BytesIO() as file_object: + etree.write(file_object, encoding='utf-8', + xml_declaration=True) + + file_object.seek(0) + file_type = get_file_type(file_object) + + self.assertEqual(file_type, 'xml') + + def test_get_file_type_identifies_tds_as_xml_file(self): + with open(asset('World Indicators.tds'), 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'xml') + + def test_get_file_type_identifies_twb_as_xml_file(self): + with open(asset('RESTAPISample.twb'), 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'xml') + + def test_get_file_type_identifies_hyper_file(self): + with open(asset('World Indicators.hyper'), 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'hyper') + + def test_get_file_type_identifies_tde_file(self): + asset_path = os.path.join(TEST_ASSET_DIR, 'Data', 'Tableau Samples', 'World Indicators.tde') + with open(asset_path, 'rb') as file_object: + file_type = get_file_type(file_object) + self.assertEqual(file_type, 'tde') + + def test_get_file_type_handles_unknown_file_type(self): + + # Create a dummy png file + with BytesIO() as file_object: + png_signature = bytes.fromhex("89504E470D0A1A0A") + file_object.write(png_signature) + file_object.seek(0) + + self.assertRaises(ValueError, get_file_type, file_object) diff --git a/test/test_workbook.py b/test/test_workbook.py index 2613a56d6..f14e4d96f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,4 +1,5 @@ import unittest +from io import BytesIO import os import re import requests_mock @@ -583,12 +584,20 @@ def test_publish_invalid_file_type(self): def test_publish_unnamed_file_object(self): new_workbook = TSC.WorkbookItem('test') - with open(os.path.join(TEST_ASSET_DIR, 'SampleDS.tds')) as f: + with open(os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')) as f: self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew ) + def test_publish_file_object_of_unknown_type_raises_exception(self): + new_workbook = TSC.WorkbookItem('test') + with BytesIO() as file_object: + file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.seek(0) + self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, + file_object, self.server.PublishMode.CreateNew) + def test_publish_multi_connection(self): new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From c74b95c498a43069faaa7a1f5d6bb574e3e55421 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 6 Nov 2020 10:31:14 -0800 Subject: [PATCH 202/567] Add all fields for users.get (#713) * Removes manual query param generation * Fixes v3.5 test issue by using regex matching * Moving all query param verifications to use regex search * Adds back removed method with deprecation warning * Adds private all_fields option to users.get --- .../server/endpoint/users_endpoint.py | 7 ++++++- tableauserverclient/server/request_options.py | 5 +++++ test/assets/user_get.xml | 4 ++-- test/test_request_option.py | 15 +++++++++++++++ test/test_user.py | 6 +++++- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index cd4ac64d4..5d3c69b26 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, UserItem, WorkbookItem, PaginationItem +from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem from ..pager import Pager import copy @@ -18,6 +18,11 @@ def baseurl(self): @api(version="2.0") def get(self, req_options=None): logger.info('Querying all users on site') + + if req_options is None: + req_options = RequestOptions() + req_options._all_fields = True + url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f94fff90f..47dfe29f8 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -69,6 +69,9 @@ def __init__(self, pagenumber=1, pagesize=100): self.sort = set() self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + def page_size(self, page_size): self.pagesize = page_size return self @@ -91,6 +94,8 @@ def get_query_params(self): filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params['filter'] = ','.join(ordered_filter_options) + if self._all_fields: + params['fields'] = '_all_' return params diff --git a/test/assets/user_get.xml b/test/assets/user_get.xml index 3165c3a4f..83557b2eb 100644 --- a/test/assets/user_get.xml +++ b/test/assets/user_get.xml @@ -2,7 +2,7 @@ - - + + \ No newline at end of file diff --git a/test/test_request_option.py b/test/test_request_option.py index 58f21aa9a..c6270dd32 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -176,6 +176,21 @@ def test_vf(self): self.assertTrue(re.search('vf_name2%24=value2', resp.request.query)) self.assertTrue(re.search('type=tabloid', resp.request.query)) + def test_all_fields(self): + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/123/views/456/data" + opts = TSC.RequestOptions() + opts._all_fields = True + + resp = self.server.users._make_request(requests.get, + url, + content=None, + request_object=opts, + auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', + content_type='text/xml') + self.assertTrue(re.search('fields=_all_', resp.request.query)) + def test_multiple_filter_options_shorthand(self): with open(FILTER_MULTIPLE, 'rb') as f: response_xml = f.read().decode('utf-8') diff --git a/test/test_user.py b/test/test_user.py index 6eb6ad223..db0f829f7 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -29,7 +29,7 @@ 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) + m.get(self.baseurl + "?fields=_all_", text=response_xml) all_users, pagination_item = self.server.users.get() self.assertEqual(2, pagination_item.total_available) @@ -40,11 +40,15 @@ def test_get(self): self.assertEqual('alice', single_user.name) self.assertEqual('Publisher', single_user.site_role) self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) + self.assertEqual('alice cook', single_user.fullname) + self.assertEqual('alicecook@test.com', single_user.email) self.assertTrue(any(user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3' for user in all_users)) single_user = next(user for user in all_users if user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3') self.assertEqual('Bob', single_user.name) self.assertEqual('Interactor', single_user.site_role) + self.assertEqual('Bob Smith', single_user.fullname) + self.assertEqual('bob@test.com', single_user.email) def test_get_empty(self): with open(GET_EMPTY_XML, 'rb') as f: From 74348f30dc5b3363da9b903c2a34a3da979f88b7 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 6 Nov 2020 14:12:23 -0800 Subject: [PATCH 203/567] Fixes boolean checks for site requests payloads (#723) --- tableauserverclient/server/request_factory.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b5dcf9912..65ce5a069 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -455,13 +455,13 @@ def update_req(self, site_item): site_element.attrib['state'] = site_item.state if site_item.storage_quota: site_element.attrib['storageQuota'] = str(site_item.storage_quota) - if site_item.disable_subscriptions: + if site_item.disable_subscriptions is not None: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() - if site_item.subscribe_others_enabled: + if site_item.subscribe_others_enabled is not None: site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: site_element.attrib['revisionLimit'] = str(site_item.revision_limit) - if site_item.subscribe_others_enabled: + if site_item.subscribe_others_enabled is not None: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() if site_item.data_acceleration_mode is not None: site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() @@ -482,7 +482,7 @@ def create_req(self, site_item): site_element.attrib['userQuota'] = str(site_item.user_quota) if site_item.storage_quota: site_element.attrib['storageQuota'] = str(site_item.storage_quota) - if site_item.disable_subscriptions: + if site_item.disable_subscriptions is not None: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() if site_item.flows_enabled is not None: site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() From 1e089b4cb1fb07ee13ddc7382293b25ce76add44 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 6 Nov 2020 14:56:40 -0800 Subject: [PATCH 204/567] Release 0.14.0 (#724) * Update publish_workbook.py (#694) * Update publish_workbook.py Added below arguments, without this there is a sign-in error on publishing a test file to Tableau Online parser.add_argument('--sitename', '-S', default='', help='sitename required') tableau_auth = TSC.TableauAuth(args.username, password,site_id=args.sitename) * Update publish_workbook.py Edits (as requested) to publish workbooks on Tableau Online which removes the Sign-in Error. * Update publish_workbook.py * Updates changelog and contributors list for v0.14.0 Co-authored-by: Madhura Selvarajan --- CHANGELOG.md | 13 +++++++++++++ CONTRIBUTORS.md | 2 ++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e8333e2..0e6be649b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.14.0 (6 Nov 2020) +* Added django-style filtering and sorting (#615) +* Added encoding tag-name before deleting (#687) +* Added 'Execute' Capability to permissions (#700) +* Added support for publishing workbook using file objects (#704) +* Added new fields to datasource_item (#705) +* Added all fields for users.get to get email and fullname (#713) +* Added support publishing datasource using file objects (#714) +* Improved request options by removing manual query param generation (#686) +* Improved publish_workbook sample to take in site (#694) +* Improved schedules.update() by removing constraint that required an interval (#711) +* Fixed site update/create not checking booleans properly (#723) + ## 0.13 (1 Sept 2020) * Added notes field to JobItem (#571) * Added webpage_url field to WorkbookItem (#661) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1f9714c6d..811f5c5bf 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -39,6 +39,8 @@ The following people have contributed to this project to make it possible, and w * [Stephen Mitchell](https://github.com/scuml) * [absentmoose](https://github.com/absentmoose) * [Paul Vickers](https://github.com/paulvic) +* [Madhura Selvarajan](https://github.com/maddy-at-leisure) +* [Niklas Nevalainen](https://github.com/nnevalainen) ## Core Team From 160fcede3b5b207b9b23f1169739d15fa4c44d9e Mon Sep 17 00:00:00 2001 From: shinchris Date: Thu, 19 Nov 2020 12:02:57 -0800 Subject: [PATCH 205/567] Fixes data_acceleration field always in workbook update payload --- tableauserverclient/server/request_factory.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 65ce5a069..2325a52de 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -593,13 +593,12 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.data_acceleration_config is not None and \ - 'acceleration_enabled' in workbook_item.data_acceleration_config: + if workbook_item.data_acceleration_config['acceleration_enabled'] is not None: data_acceleration_config = workbook_item.data_acceleration_config data_acceleration_element = ET.SubElement(workbook_element, 'dataAccelerationConfig') data_acceleration_element.attrib['accelerationEnabled'] = str(data_acceleration_config ["acceleration_enabled"]).lower() - if "accelerate_now" in data_acceleration_config: + if data_acceleration_config['accelerate_now'] is not None: data_acceleration_element.attrib['accelerateNow'] = str(data_acceleration_config ["accelerate_now"]).lower() From ef95e53924b3c099419b8f23017ca98bf9cf2e7c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 19 Nov 2020 22:00:15 -0800 Subject: [PATCH 206/567] Improve debug logging - show contents of methods such as put, post - for empty responses, don't try to print them (avoid [Truncated File Contents] which might appear like an error) --- tableauserverclient/server/endpoint/endpoint.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 7f7fec3ee..8d5597ed4 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -50,6 +50,10 @@ def _make_request(self, method, url, content=None, request_object=None, if content is not None: parameters['data'] = content + logger.debug(u'request {}, url: {}'.format(method.__name__, url)) + if content: + logger.debug(u'request content: {}'.format(content)) + server_response = method(url, **parameters) self.parent_srv._namespace.detect(server_response.content) self._check_status(server_response) @@ -62,7 +66,8 @@ def _make_request(self, method, url, content=None, request_object=None, return server_response def _check_status(self, server_response): - logger.debug(self._safe_to_log(server_response)) + if len(server_response.content) > 0: + logger.debug(self._safe_to_log(server_response)) if server_response.status_code >= 500: raise InternalServerError(server_response) elif server_response.status_code not in Success_codes: From 00bde4e3adebc77a204ba515deca8cf423c5b530 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 19 Nov 2020 22:13:55 -0800 Subject: [PATCH 207/567] Add Python 3.9 for CI builds --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 41316d700..9085632f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9" # command to install dependencies install: - "pip install -e ." From 3f9c65d64f709bd411404b8133b1ff7f589af81b Mon Sep 17 00:00:00 2001 From: shinchris Date: Fri, 20 Nov 2020 14:33:55 -0800 Subject: [PATCH 208/567] Adds support for older server versions that expect different query string encoding --- .../server/endpoint/endpoint.py | 35 +++++----- tableauserverclient/server/request_options.py | 4 +- test/test_request_option.py | 66 +++++++++++++------ test/test_requests.py | 12 +--- test/test_sort.py | 29 ++------ 5 files changed, 72 insertions(+), 74 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 7f7fec3ee..c2d0c67e8 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError +from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError, EndpointUnavailableError from functools import wraps from xml.etree.ElementTree import ParseError from ..query import QuerySet @@ -39,11 +39,9 @@ def _safe_to_log(server_response): else: return server_response.content - def _make_request(self, method, url, content=None, request_object=None, - auth_token=None, content_type=None, parameters=None): + def _make_request(self, method, url, content=None, auth_token=None, + content_type=None, parameters=None): parameters = parameters or {} - if request_object is not None: - parameters["params"] = request_object.get_query_params() parameters.update(self.parent_srv.http_options) parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) @@ -78,28 +76,33 @@ def _check_status(self, server_response): # anything else re-raise here raise - def get_unauthenticated_request(self, url, request_object=None): - return self._make_request(self.parent_srv.session.get, url, request_object=request_object) + def get_unauthenticated_request(self, url): + return self._make_request(self.parent_srv.session.get, url) def get_request(self, url, request_object=None, parameters=None): + if request_object is not None: + try: + # Query param encoding is not needed for versions before 3.7 (2020.1) + self.parent_srv.assert_at_least_version("3.7") + parameters = parameters or {} + parameters["params"] = request_object.get_query_params() + except EndpointUnavailableError: + url = request_object.apply_query_params(url) + return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, - request_object=request_object, parameters=parameters) + parameters=parameters) def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) def put_request(self, url, xml_request=None, content_type='text/xml'): - return self._make_request(self.parent_srv.session.put, url, - content=xml_request, - auth_token=self.parent_srv.auth_token, - content_type=content_type) + return self._make_request(self.parent_srv.session.put, url, content=xml_request, + auth_token=self.parent_srv.auth_token, content_type=content_type) def post_request(self, url, xml_request, content_type='text/xml'): - return self._make_request(self.parent_srv.session.post, url, - content=xml_request, - auth_token=self.parent_srv.auth_token, - content_type=content_type) + return self._make_request(self.parent_srv.session.post, url, content=xml_request, + auth_token=self.parent_srv.auth_token, content_type=content_type) def api(version): diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 47dfe29f8..22d0a4ef0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -2,10 +2,8 @@ class RequestOptionsBase(object): + # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): - import warnings - warnings.simplefilter('always', DeprecationWarning) - warnings.warn('apply_query_params is deprecated, please use get_query_params instead.', DeprecationWarning) try: params = self.get_query_params() params_list = ["{}={}".format(k, v) for (k, v) in params.items()] diff --git a/test/test_request_option.py b/test/test_request_option.py index c6270dd32..37b4fc945 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -20,6 +20,7 @@ def setUp(self): self.server = TSC.Server('http://test') # Fake signin + self.server.version = "3.10" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -141,54 +142,77 @@ def test_multiple_filter_options(self): def test_double_query_params(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/12345/views?queryParamExists=true" + url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ['stocks', 'market'])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Direction.Asc)) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('queryparamexists=true', resp.request.query)) self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) + self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + + # Test req_options for versions below 3.7 + def test_filter_sort_legacy(self): + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views?queryParamExists=true" + opts = TSC.RequestOptions() + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, + TSC.RequestOptions.Operator.In, + ['stocks', 'market'])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Direction.Asc)) + + resp = self.server.workbooks.get_request(url, request_object=opts) + self.assertTrue(re.search('queryparamexists=true', resp.request.query)) + self.assertTrue(re.search('filter=tags:in:%5bstocks,market%5d', resp.request.query)) + self.assertTrue(re.search('sort=name:asc', resp.request.query)) def test_vf(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/123/views/456/data" + url = self.baseurl + "/views/456/data" opts = TSC.PDFRequestOptions() opts.vf("name1#", "value1") opts.vf("name2$", "value2") opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('vf_name1%23=value1', resp.request.query)) self.assertTrue(re.search('vf_name2%24=value2', resp.request.query)) self.assertTrue(re.search('type=tabloid', resp.request.query)) + # Test req_options for versions beloe 3.7 + def test_vf_legacy(self): + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.vf("name1@", "value1") + opts.vf("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = self.server.workbooks.get_request(url, request_object=opts) + self.assertTrue(re.search('vf_name1@=value1', resp.request.query)) + self.assertTrue(re.search('vf_name2\\$=value2', resp.request.query)) + self.assertTrue(re.search('type=tabloid', resp.request.query)) + def test_all_fields(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/123/views/456/data" + url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() opts._all_fields = True - resp = self.server.users._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search('fields=_all_', resp.request.query)) def test_multiple_filter_options_shorthand(self): diff --git a/test/test_requests.py b/test/test_requests.py index c21853dbb..2976e8f3e 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -23,12 +23,7 @@ def test_make_get_request(self): m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=15) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagesize=13', resp.request.query)) self.assertTrue(re.search('pagenumber=15', resp.request.query)) @@ -37,10 +32,7 @@ def test_make_post_request(self): with requests_mock.mock() as m: m.post(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - resp = self.server.workbooks._make_request(requests.post, - url, - content=b'1337', - request_object=None, + resp = self.server.workbooks._make_request(requests.post, url, content=b'1337', auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='multipart/mixed') self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') diff --git a/test/test_sort.py b/test/test_sort.py index 106153cf6..525f0441d 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -8,6 +8,7 @@ class SortTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') + self.server.version = "3.7" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' self.baseurl = self.server.workbooks.baseurl @@ -24,12 +25,7 @@ def test_filter_equals(self): TSC.RequestOptions.Operator.Equals, 'Superstore')) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) @@ -53,12 +49,7 @@ def test_filter_in(self): TSC.RequestOptions.Operator.In, ['stocks', 'market'])) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) @@ -71,12 +62,7 @@ def test_sort_asc(self): opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) @@ -96,12 +82,7 @@ def test_filter_combo(self): TSC.RequestOptions.Operator.Equals, 'Publisher')) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) expected = 'pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a' \ '2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher' From 0bcff26cf1d588b6eab1007b32268db4c2997e58 Mon Sep 17 00:00:00 2001 From: shinchris Date: Fri, 20 Nov 2020 14:38:24 -0800 Subject: [PATCH 209/567] Minor fixes --- tableauserverclient/server/endpoint/endpoint.py | 17 +++++++++++------ test/test_sort.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c2d0c67e8..170f79f6e 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -82,14 +82,15 @@ def get_unauthenticated_request(self, url): def get_request(self, url, request_object=None, parameters=None): if request_object is not None: try: - # Query param encoding is not needed for versions before 3.7 (2020.1) + # Query param delimiters don't need to be encoded for versions before 3.7 (2020.1) self.parent_srv.assert_at_least_version("3.7") parameters = parameters or {} parameters["params"] = request_object.get_query_params() except EndpointUnavailableError: url = request_object.apply_query_params(url) - return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, + return self._make_request(self.parent_srv.session.get, url, + auth_token=self.parent_srv.auth_token, parameters=parameters) def delete_request(self, url): @@ -97,12 +98,16 @@ def delete_request(self, url): self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) def put_request(self, url, xml_request=None, content_type='text/xml'): - return self._make_request(self.parent_srv.session.put, url, content=xml_request, - auth_token=self.parent_srv.auth_token, content_type=content_type) + return self._make_request(self.parent_srv.session.put, url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type) def post_request(self, url, xml_request, content_type='text/xml'): - return self._make_request(self.parent_srv.session.post, url, content=xml_request, - auth_token=self.parent_srv.auth_token, content_type=content_type) + return self._make_request(self.parent_srv.session.post, url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type) def api(version): diff --git a/test/test_sort.py b/test/test_sort.py index 525f0441d..0572a1e10 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -8,7 +8,7 @@ class SortTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - self.server.version = "3.7" + self.server.version = "3.10" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' self.baseurl = self.server.workbooks.baseurl From 0aca85b743b9fa000892320b716abb3c098aaa1e Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 2 Dec 2020 15:26:19 -0800 Subject: [PATCH 210/567] Limit request content to 1000 bytes, remove redundant response logging --- tableauserverclient/server/endpoint/endpoint.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8d5597ed4..0060510d8 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -52,7 +52,7 @@ def _make_request(self, method, url, content=None, request_object=None, logger.debug(u'request {}, url: {}'.format(method.__name__, url)) if content: - logger.debug(u'request content: {}'.format(content)) + logger.debug(u'request content: {}'.format(content[:1000])) server_response = method(url, **parameters) self.parent_srv._namespace.detect(server_response.content) @@ -60,14 +60,12 @@ def _make_request(self, method, url, content=None, request_object=None, # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - if server_response.encoding: + if len(server_response.content) > 0 and server_response.encoding: logger.debug(u'Server response from {0}:\n\t{1}'.format( url, server_response.content.decode(server_response.encoding))) return server_response def _check_status(self, server_response): - if len(server_response.content) > 0: - logger.debug(self._safe_to_log(server_response)) if server_response.status_code >= 500: raise InternalServerError(server_response) elif server_response.status_code not in Success_codes: From 15f7b56f2fc7666c31d893a5d79e45f904b56365 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 3 Dec 2020 15:12:34 -0800 Subject: [PATCH 211/567] Add Get View by ID --- .../server/endpoint/views_endpoint.py | 10 +++++++++ test/assets/view_get_id.xml | 12 ++++++++++ test/test_view.py | 22 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 test/assets/view_get_id.xml diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index cd2792f5d..8c848c295 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -36,6 +36,16 @@ def get(self, req_options=None, usage=False): all_view_items = ViewItem.from_response(server_response.content, self.parent_srv.namespace) return all_view_items, pagination_item + @api(version="3.1") + def get_by_id(self, view_id): + if not view_id: + error = "View item missing ID." + raise MissingRequiredFieldError(error) + logger.info('Querying single view (ID: {0})'.format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) + server_response = self.get_request(url) + return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="2.0") def populate_preview_image(self, view_item): if not view_item.id or not view_item.workbook_id: diff --git a/test/assets/view_get_id.xml b/test/assets/view_get_id.xml new file mode 100644 index 000000000..6110a0a3a --- /dev/null +++ b/test/assets/view_get_id.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/test/test_view.py b/test/test_view.py index 1bd88995a..e32971ea2 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -10,6 +10,7 @@ ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'view_add_tags.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'view_get.xml') +GET_XML_ID = os.path.join(TEST_ASSET_DIR, 'view_get_id.xml') GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, 'view_get_usage.xml') POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'Sample View Image.png') POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') @@ -60,6 +61,27 @@ def test_get(self): self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) self.assertEqual('story', all_views[1].sheet_type) + def test_get_by_id(self): + with open(GET_XML_ID, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5', text=response_xml) + view = self.server.views.get_by_id('d79634e1-6063-4ec9-95ff-50acbf609ff5') + + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', view.id) + self.assertEqual('ENDANGERED SAFARI', view.name) + self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', view.content_url) + self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', view.workbook_id) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', view.owner_id) + self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', view.project_id) + self.assertEqual(set(['tag1', 'tag2']), view.tags) + self.assertEqual('2002-05-30T09:00:00Z', format_datetime(view.created_at)) + self.assertEqual('2002-06-05T08:00:59Z', format_datetime(view.updated_at)) + self.assertEqual('story', view.sheet_type) + + def test_get_by_id_missing_id(self): + self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) + def test_get_with_usage(self): with open(GET_XML_USAGE, 'rb') as f: response_xml = f.read().decode('utf-8') From e624178b44b0112eff2f6773b62d01ea87c66bf8 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 9 Dec 2020 11:03:08 -0800 Subject: [PATCH 212/567] Fixes issue #754 by moving file read logic inside generator --- .../server/endpoint/fileuploads_endpoint.py | 16 +++-- test/assets/fileupload_append.xml | 3 + test/assets/fileupload_initialize.xml | 3 + test/test_fileuploads.py | 70 +++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 test/assets/fileupload_append.xml create mode 100644 test/assets/fileupload_initialize.xml create mode 100644 test/test_fileuploads.py diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 62224c894..c89a595d4 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -40,10 +40,18 @@ def append(self, xml_request, content_type): return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def read_chunks(self, file): + file_opened = False + try: + file_content = open(file, 'rb') + file_opened = True + except TypeError: + file_content = file while True: - chunked_content = file.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE) if not chunked_content: + if file_opened: + file_content.close() break yield chunked_content @@ -52,11 +60,7 @@ def upload_chunks(cls, parent_srv, file): file_uploader = cls(parent_srv) upload_id = file_uploader.initiate() - try: - with open(file, 'rb') as f: - chunks = file_uploader.read_chunks(f) - except TypeError: - chunks = file_uploader.read_chunks(file) + chunks = file_uploader.read_chunks(file) for chunk in chunks: xml_request, content_type = RequestFactory.Fileupload.chunk_req(chunk) fileupload_item = file_uploader.append(xml_request, content_type) diff --git a/test/assets/fileupload_append.xml b/test/assets/fileupload_append.xml new file mode 100644 index 000000000..325ee66a9 --- /dev/null +++ b/test/assets/fileupload_append.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/test/assets/fileupload_initialize.xml b/test/assets/fileupload_initialize.xml new file mode 100644 index 000000000..073ad0edc --- /dev/null +++ b/test/assets/fileupload_initialize.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py new file mode 100644 index 000000000..9d115636f --- /dev/null +++ b/test/test_fileuploads.py @@ -0,0 +1,70 @@ +import os +import requests_mock +import unittest + +from ._utils import asset +from tableauserverclient.server import Server +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, 'fileupload_initialize.xml') +FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, 'fileupload_append.xml') + + +class FileuploadsTests(unittest.TestCase): + def setUp(self): + self.server = Server('http://test') + + # Fake sign in + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = '{}/sites/{}/fileUploads'.format(self.server.baseurl, self.server.site_id) + + def test_read_chunks_file_path(self): + fileuploads = Fileuploads(self.server) + + file_path = asset('SampleWB.twbx') + chunks = fileuploads.read_chunks(file_path) + for chunk in chunks: + self.assertIsNotNone(chunk) + + def test_read_chunks_file_object(self): + fileuploads = Fileuploads(self.server) + + with open(asset('SampleWB.twbx'), 'rb') as f: + chunks = fileuploads.read_chunks(f) + for chunk in chunks: + self.assertIsNotNone(chunk) + + def test_upload_chunks_file_path(self): + fileuploads = Fileuploads(self.server) + file_path = asset('SampleWB.twbx') + upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + + with open(FILEUPLOAD_INITIALIZE, 'rb') as f: + initialize_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_APPEND, 'rb') as f: + append_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=initialize_response_xml) + m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + actual = fileuploads.upload_chunks(self.server, file_path) + + self.assertEqual(upload_id, actual) + + def test_upload_chunks_file_object(self): + fileuploads = Fileuploads(self.server) + upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + + with open(asset('SampleWB.twbx'), 'rb') as file_content: + with open(FILEUPLOAD_INITIALIZE, 'rb') as f: + initialize_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_APPEND, 'rb') as f: + append_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=initialize_response_xml) + m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + actual = fileuploads.upload_chunks(self.server, file_content) + + self.assertEqual(upload_id, actual) From 138476f08b0c6275d3f24fff7654d95daf9a0d72 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 9 Dec 2020 15:49:23 -0800 Subject: [PATCH 213/567] Merge pull request #745 from tableau/fix_732 Server versions before 2020.1 do not accept encoded query param delimiters --- .../server/endpoint/endpoint.py | 26 +++++--- tableauserverclient/server/request_options.py | 4 +- test/test_request_option.py | 66 +++++++++++++------ test/test_requests.py | 12 +--- test/test_sort.py | 29 ++------ 5 files changed, 70 insertions(+), 67 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 7f7fec3ee..170f79f6e 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError +from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError, EndpointUnavailableError from functools import wraps from xml.etree.ElementTree import ParseError from ..query import QuerySet @@ -39,11 +39,9 @@ def _safe_to_log(server_response): else: return server_response.content - def _make_request(self, method, url, content=None, request_object=None, - auth_token=None, content_type=None, parameters=None): + def _make_request(self, method, url, content=None, auth_token=None, + content_type=None, parameters=None): parameters = parameters or {} - if request_object is not None: - parameters["params"] = request_object.get_query_params() parameters.update(self.parent_srv.http_options) parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) @@ -78,12 +76,22 @@ def _check_status(self, server_response): # anything else re-raise here raise - def get_unauthenticated_request(self, url, request_object=None): - return self._make_request(self.parent_srv.session.get, url, request_object=request_object) + def get_unauthenticated_request(self, url): + return self._make_request(self.parent_srv.session.get, url) def get_request(self, url, request_object=None, parameters=None): - return self._make_request(self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, - request_object=request_object, parameters=parameters) + if request_object is not None: + try: + # Query param delimiters don't need to be encoded for versions before 3.7 (2020.1) + self.parent_srv.assert_at_least_version("3.7") + parameters = parameters or {} + parameters["params"] = request_object.get_query_params() + except EndpointUnavailableError: + url = request_object.apply_query_params(url) + + return self._make_request(self.parent_srv.session.get, url, + auth_token=self.parent_srv.auth_token, + parameters=parameters) def delete_request(self, url): # We don't return anything for a delete diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 47dfe29f8..22d0a4ef0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -2,10 +2,8 @@ class RequestOptionsBase(object): + # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): - import warnings - warnings.simplefilter('always', DeprecationWarning) - warnings.warn('apply_query_params is deprecated, please use get_query_params instead.', DeprecationWarning) try: params = self.get_query_params() params_list = ["{}={}".format(k, v) for (k, v) in params.items()] diff --git a/test/test_request_option.py b/test/test_request_option.py index c6270dd32..37b4fc945 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -20,6 +20,7 @@ def setUp(self): self.server = TSC.Server('http://test') # Fake signin + self.server.version = "3.10" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -141,54 +142,77 @@ def test_multiple_filter_options(self): def test_double_query_params(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/12345/views?queryParamExists=true" + url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ['stocks', 'market'])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Direction.Asc)) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('queryparamexists=true', resp.request.query)) self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) + self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + + # Test req_options for versions below 3.7 + def test_filter_sort_legacy(self): + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views?queryParamExists=true" + opts = TSC.RequestOptions() + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, + TSC.RequestOptions.Operator.In, + ['stocks', 'market'])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Direction.Asc)) + + resp = self.server.workbooks.get_request(url, request_object=opts) + self.assertTrue(re.search('queryparamexists=true', resp.request.query)) + self.assertTrue(re.search('filter=tags:in:%5bstocks,market%5d', resp.request.query)) + self.assertTrue(re.search('sort=name:asc', resp.request.query)) def test_vf(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/123/views/456/data" + url = self.baseurl + "/views/456/data" opts = TSC.PDFRequestOptions() opts.vf("name1#", "value1") opts.vf("name2$", "value2") opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('vf_name1%23=value1', resp.request.query)) self.assertTrue(re.search('vf_name2%24=value2', resp.request.query)) self.assertTrue(re.search('type=tabloid', resp.request.query)) + # Test req_options for versions beloe 3.7 + def test_vf_legacy(self): + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.vf("name1@", "value1") + opts.vf("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = self.server.workbooks.get_request(url, request_object=opts) + self.assertTrue(re.search('vf_name1@=value1', resp.request.query)) + self.assertTrue(re.search('vf_name2\\$=value2', resp.request.query)) + self.assertTrue(re.search('type=tabloid', resp.request.query)) + def test_all_fields(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/123/views/456/data" + url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() opts._all_fields = True - resp = self.server.users._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search('fields=_all_', resp.request.query)) def test_multiple_filter_options_shorthand(self): diff --git a/test/test_requests.py b/test/test_requests.py index c21853dbb..2976e8f3e 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -23,12 +23,7 @@ def test_make_get_request(self): m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=15) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagesize=13', resp.request.query)) self.assertTrue(re.search('pagenumber=15', resp.request.query)) @@ -37,10 +32,7 @@ def test_make_post_request(self): with requests_mock.mock() as m: m.post(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - resp = self.server.workbooks._make_request(requests.post, - url, - content=b'1337', - request_object=None, + resp = self.server.workbooks._make_request(requests.post, url, content=b'1337', auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', content_type='multipart/mixed') self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') diff --git a/test/test_sort.py b/test/test_sort.py index 106153cf6..0572a1e10 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -8,6 +8,7 @@ class SortTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') + self.server.version = "3.10" self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' self.baseurl = self.server.workbooks.baseurl @@ -24,12 +25,7 @@ def test_filter_equals(self): TSC.RequestOptions.Operator.Equals, 'Superstore')) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) @@ -53,12 +49,7 @@ def test_filter_in(self): TSC.RequestOptions.Operator.In, ['stocks', 'market'])) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) @@ -71,12 +62,7 @@ def test_sort_asc(self): opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) self.assertTrue(re.search('pagenumber=13', resp.request.query)) self.assertTrue(re.search('pagesize=13', resp.request.query)) @@ -96,12 +82,7 @@ def test_filter_combo(self): TSC.RequestOptions.Operator.Equals, 'Publisher')) - resp = self.server.workbooks._make_request(requests.get, - url, - content=None, - request_object=opts, - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='text/xml') + resp = self.server.workbooks.get_request(url, request_object=opts) expected = 'pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a' \ '2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher' From 41fd8f2af366f7da86c17cbae4fb5cc89895c970 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 9 Dec 2020 15:49:48 -0800 Subject: [PATCH 214/567] Merge pull request #757 from tableau/fix_754 Fixes issue #754 by moving file read logic inside generator --- .../server/endpoint/fileuploads_endpoint.py | 16 +++-- test/assets/fileupload_append.xml | 3 + test/assets/fileupload_initialize.xml | 3 + test/test_fileuploads.py | 70 +++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 test/assets/fileupload_append.xml create mode 100644 test/assets/fileupload_initialize.xml create mode 100644 test/test_fileuploads.py diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 62224c894..c89a595d4 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -40,10 +40,18 @@ def append(self, xml_request, content_type): return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def read_chunks(self, file): + file_opened = False + try: + file_content = open(file, 'rb') + file_opened = True + except TypeError: + file_content = file while True: - chunked_content = file.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE) if not chunked_content: + if file_opened: + file_content.close() break yield chunked_content @@ -52,11 +60,7 @@ def upload_chunks(cls, parent_srv, file): file_uploader = cls(parent_srv) upload_id = file_uploader.initiate() - try: - with open(file, 'rb') as f: - chunks = file_uploader.read_chunks(f) - except TypeError: - chunks = file_uploader.read_chunks(file) + chunks = file_uploader.read_chunks(file) for chunk in chunks: xml_request, content_type = RequestFactory.Fileupload.chunk_req(chunk) fileupload_item = file_uploader.append(xml_request, content_type) diff --git a/test/assets/fileupload_append.xml b/test/assets/fileupload_append.xml new file mode 100644 index 000000000..325ee66a9 --- /dev/null +++ b/test/assets/fileupload_append.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/test/assets/fileupload_initialize.xml b/test/assets/fileupload_initialize.xml new file mode 100644 index 000000000..073ad0edc --- /dev/null +++ b/test/assets/fileupload_initialize.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py new file mode 100644 index 000000000..9d115636f --- /dev/null +++ b/test/test_fileuploads.py @@ -0,0 +1,70 @@ +import os +import requests_mock +import unittest + +from ._utils import asset +from tableauserverclient.server import Server +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, 'fileupload_initialize.xml') +FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, 'fileupload_append.xml') + + +class FileuploadsTests(unittest.TestCase): + def setUp(self): + self.server = Server('http://test') + + # Fake sign in + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = '{}/sites/{}/fileUploads'.format(self.server.baseurl, self.server.site_id) + + def test_read_chunks_file_path(self): + fileuploads = Fileuploads(self.server) + + file_path = asset('SampleWB.twbx') + chunks = fileuploads.read_chunks(file_path) + for chunk in chunks: + self.assertIsNotNone(chunk) + + def test_read_chunks_file_object(self): + fileuploads = Fileuploads(self.server) + + with open(asset('SampleWB.twbx'), 'rb') as f: + chunks = fileuploads.read_chunks(f) + for chunk in chunks: + self.assertIsNotNone(chunk) + + def test_upload_chunks_file_path(self): + fileuploads = Fileuploads(self.server) + file_path = asset('SampleWB.twbx') + upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + + with open(FILEUPLOAD_INITIALIZE, 'rb') as f: + initialize_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_APPEND, 'rb') as f: + append_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=initialize_response_xml) + m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + actual = fileuploads.upload_chunks(self.server, file_path) + + self.assertEqual(upload_id, actual) + + def test_upload_chunks_file_object(self): + fileuploads = Fileuploads(self.server) + upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + + with open(asset('SampleWB.twbx'), 'rb') as file_content: + with open(FILEUPLOAD_INITIALIZE, 'rb') as f: + initialize_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_APPEND, 'rb') as f: + append_response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=initialize_response_xml) + m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + actual = fileuploads.upload_chunks(self.server, file_content) + + self.assertEqual(upload_id, actual) From 1fc349c215e86167021a303c080c4823d3116d7c Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 9 Dec 2020 16:30:03 -0800 Subject: [PATCH 215/567] Updates changelog for v0.14.1 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6be649b..85dc8a702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.14.1 (9 Dec 2020) +* Fixed filter query issue for server version below 2020.1 (#745) +* Fixed large workbook/datasource publish issue (#757) + ## 0.14.0 (6 Nov 2020) * Added django-style filtering and sorting (#615) * Added encoding tag-name before deleting (#687) From 861c65307916811bb0bbc025298947af262c7180 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Thu, 7 Jan 2021 09:48:12 -0800 Subject: [PATCH 216/567] Improves group creation for both local and AD (#770) * Fixes local and ad group creation * Adds tests for validating group field values --- tableauserverclient/models/group_item.py | 14 +++++++++----- tableauserverclient/server/request_factory.py | 8 +++----- test/test_group_model.py | 10 ++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index ba9beec27..3cf82621f 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -9,13 +9,17 @@ class GroupItem(object): tag_name = 'group' - def __init__(self, name=None): - self._domain_name = None + class LicenseMode: + onLogin = 'onLogin' + onSync = 'onSync' + + def __init__(self, name=None, domain_name=None): self._id = None - self._users = None - self.name = name self._license_mode = None self._minimum_site_role = None + self._users = None + self.name = name + self.domain_name = domain_name @property def domain_name(self): @@ -43,8 +47,8 @@ def license_mode(self): return self._license_mode @license_mode.setter + @property_is_enum(LicenseMode) def license_mode(self, value): - # valid values = onSync, onLogin self._license_mode = value @property diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 2325a52de..d6e962be3 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -277,10 +277,8 @@ def create_local_req(self, group_item): xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name - if group_item.license_mode is not None: - group_element.attrib['grantLicenseMode'] = group_item.license_mode if group_item.minimum_site_role is not None: - group_element.attrib['SiteRole'] = group_item.minimum_site_role + group_element.attrib['minimumSiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) def create_ad_req(self, group_item): @@ -295,9 +293,9 @@ def create_ad_req(self, group_item): import_element.attrib['domainName'] = group_item.domain_name if group_item.license_mode is not None: - import_element.attrib['grantLicenseMode'] = group_item.license + import_element.attrib['grantLicenseMode'] = group_item.license_mode if group_item.minimum_site_role is not None: - import_element.attrib['SiteRole'] = group_item.minimum_site_role + import_element.attrib['siteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) def update_req(self, group_item, default_site_role=None): diff --git a/test/test_group_model.py b/test/test_group_model.py index eb11adcdd..617a5d954 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -12,3 +12,13 @@ def test_invalid_name(self): with self.assertRaises(ValueError): group.name = "" + + def test_invalid_minimum_site_role(self): + group = TSC.GroupItem("grp") + with self.assertRaises(ValueError): + group.minimum_site_role = "Captain" + + def test_invalid_license_mode(self): + group = TSC.GroupItem("grp") + with self.assertRaises(ValueError): + group.license_mode = "off" From 1c7480f7127235ddf6331f99aed40208aa0d0890 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Wed, 20 Jan 2021 13:05:13 -0800 Subject: [PATCH 217/567] Fixes groups.update to match server requests/responses (#772) --- tableauserverclient/models/group_item.py | 21 ++++++++-------- .../server/endpoint/groups_endpoint.py | 21 ++++++++++++---- tableauserverclient/server/request_factory.py | 25 ++++++++++++++----- test/assets/group_update.xml | 6 ++--- test/test_group.py | 10 ++++++++ 5 files changed, 59 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 3cf82621f..af9465dfb 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -83,17 +83,18 @@ def from_response(cls, resp, ns): name = group_xml.get('name', None) group_item = cls(name) group_item._id = group_xml.get('id', None) - # AD groups have an extra element under this + + # Domain name is returned in a domain element for some calls + domain_elem = group_xml.find('.//t:domain', namespaces=ns) + if domain_elem is not None: + group_item.domain_name = domain_elem.get('name', None) + + # Import element is returned for both local and AD groups (2020.3+) import_elem = group_xml.find('.//t:import', namespaces=ns) - if (import_elem is not None): - group_item.domain_name = import_elem.get('domainName') - group_item.license_mode = import_elem.get('grantLicenseMode') - group_item.minimum_site_role = import_elem.get('siteRole') - else: - # local group, we will just have two extra attributes here - group_item.domain_name = 'local' - group_item.license_mode = group_xml.get('grantLicenseMode') - group_item.minimum_site_role = group_xml.get('siteRole') + if import_elem is not None: + group_item.domain_name = import_elem.get('domainName', None) + group_item.license_mode = import_elem.get('grantLicenseMode', None) + group_item.minimum_site_role = import_elem.get('siteRole', None) all_group_items.append(group_item) return all_group_items diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c873dc159..6a9b81afd 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -7,8 +7,6 @@ logger = logging.getLogger('tableau.endpoint.groups') -UNLICENSED_USER = UserItem.Roles.Unlicensed - class Groups(Endpoint): @property @@ -58,15 +56,28 @@ def delete(self, group_id): logger.info('Deleted single group (ID: {0})'.format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=UNLICENSED_USER, as_job=False): + def update(self, group_item, default_site_role=None, as_job=False): + # (1/8/2021): Deprecated starting v0.15 + if default_site_role is not None: + import warnings + warnings.simplefilter('always', DeprecationWarning) + warnings.warn('Groups.update(...default_site_role=""...) is deprecated, ' + 'please set the minimum_site_role field of GroupItem', + DeprecationWarning) + group_item.minimum_site_role = default_site_role + if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) + if as_job and (group_item.domain_name is None or group_item.domain_name == 'local'): + error = "Local groups cannot be updated asynchronously." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, group_item.id) - update_req = RequestFactory.Group.update_req(group_item, default_site_role) + update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) logger.info('Updated group item (ID: {0})'.format(group_item.id)) - if (as_job): + if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d6e962be3..14c755bbd 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -299,17 +299,30 @@ def create_ad_req(self, group_item): return ET.tostring(xml_request) def update_req(self, group_item, default_site_role=None): + # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: + import warnings + warnings.simplefilter('always', DeprecationWarning) + warnings.warn('RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' + 'please set the minimum_site_role field of GroupItem', + DeprecationWarning) group_item.minimum_site_role = default_site_role + xml_request = ET.Element('tsRequest') group_element = ET.SubElement(xml_request, 'group') group_element.attrib['name'] = group_item.name - if group_item.domain_name != 'local': - project_element = ET.SubElement(group_element, 'import') - project_element.attrib['source'] = "ActiveDirectory" - project_element.attrib['domainName'] = group_item.domain_name - project_element.attrib['siteRole'] = group_item.minimum_site_role - project_element.attrib['grantLicenseMode'] = group_item.license_mode + if group_item.domain_name is not None and group_item.domain_name != 'local': + # Import element is only accepted in the request for AD groups + import_element = ET.SubElement(group_element, 'import') + import_element.attrib['source'] = "ActiveDirectory" + import_element.attrib['domainName'] = group_item.domain_name + import_element.attrib['siteRole'] = group_item.minimum_site_role + if group_item.license_mode is not None: + import_element.attrib['grantLicenseMode'] = group_item.license_mode + else: + # Local group request does not accept an 'import' element + if group_item.minimum_site_role is not None: + group_element.attrib['minimumSiteRole'] = group_item.minimum_site_role return ET.tostring(xml_request) diff --git a/test/assets/group_update.xml b/test/assets/group_update.xml index 828e3f251..3c54524c0 100644 --- a/test/assets/group_update.xml +++ b/test/assets/group_update.xml @@ -2,7 +2,7 @@ - /> + + + \ No newline at end of file diff --git a/test/test_group.py b/test/test_group.py index 8aeb4817d..082a63ba3 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -224,3 +224,13 @@ def test_update(self): self.assertEqual('Group updated name', group.name) self.assertEqual('ExplorerCanPublish', group.minimum_site_role) self.assertEqual('onLogin', group.license_mode) + + # async update is not supported for local groups + def test_update_local_async(self): + group = TSC.GroupItem("myGroup") + group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) + + # mimic group returned from server where domain name is set to 'local' + group.domain_name = "local" + self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) From f566c050ed6103de5b686c785d7b355585878377 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 1 Feb 2021 13:15:56 -0600 Subject: [PATCH 218/567] Fetch project owner on get (#784) Fetch project owner on get, and throw NotImplementedError if we try to set it, until Server itself supports the action Co-authored-by: Jordan Woods --- tableauserverclient/models/project_item.py | 21 +++++++++++++++++---- test/assets/project_get.xml | 6 +++--- test/test_project.py | 7 +++++-- test/test_project_model.py | 5 +++++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index d6aece83b..4cfbcb4e9 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -74,6 +74,14 @@ def name(self): def name(self, value): self._name = value + @property + def owner_id(self): + return self._owner_id + + @owner_id.setter + def owner_id(self, value): + raise NotImplementedError('REST API does not currently support updating project owner.') + def is_default(self): return self.name.lower() == 'default' @@ -86,7 +94,7 @@ def _parse_common_tags(self, project_xml, ns): self._set_values(None, name, description, content_permissions, parent_id) return self - def _set_values(self, project_id, name, description, content_permissions, parent_id): + def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): if project_id is not None: self._id = project_id if name: @@ -97,6 +105,8 @@ def _set_values(self, project_id, name, description, content_permissions, parent self._content_permissions = content_permissions if parent_id: self.parent_id = parent_id + if owner_id: + self._owner_id = owner_id def _set_permissions(self, permissions): self._permissions = permissions @@ -111,9 +121,9 @@ def from_response(cls, resp, ns): all_project_xml = parsed_response.findall('.//t:project', namespaces=ns) for project_xml in all_project_xml: - (id, name, description, content_permissions, parent_id) = cls._parse_element(project_xml) + (id, name, description, content_permissions, parent_id, owner_id) = cls._parse_element(project_xml) project_item = cls(name) - project_item._set_values(id, name, description, content_permissions, parent_id) + project_item._set_values(id, name, description, content_permissions, parent_id, owner_id) all_project_items.append(project_item) return all_project_items @@ -124,5 +134,8 @@ def _parse_element(project_xml): description = project_xml.get('description', None) content_permissions = project_xml.get('contentPermissions', None) parent_id = project_xml.get('parentProjectId', None) + owner_id = None + for owner in project_xml: + owner_id = owner.get('id', None) - return id, name, description, content_permissions, parent_id + return id, name, description, content_permissions, parent_id, owner_id diff --git a/test/assets/project_get.xml b/test/assets/project_get.xml index 777412b30..7898c8c13 100644 --- a/test/assets/project_get.xml +++ b/test/assets/project_get.xml @@ -2,8 +2,8 @@ - - - + + + diff --git a/test/test_project.py b/test/test_project.py index 5e9869c6e..045f0a43e 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -39,16 +39,19 @@ def test_get(self): all_projects[0].description) self.assertEqual('ManagedByOwner', all_projects[0].content_permissions) self.assertEqual(None, all_projects[0].parent_id) + self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[0].owner_id) self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[1].id) self.assertEqual('Tableau', all_projects[1].name) self.assertEqual('ManagedByOwner', all_projects[1].content_permissions) self.assertEqual(None, all_projects[1].parent_id) + self.assertEqual('2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3', all_projects[1].owner_id) self.assertEqual('4cc52973-5e3a-4d1f-a4fb-5b5f73796edf', all_projects[2].id) self.assertEqual('Tableau > Child 1', all_projects[2].name) self.assertEqual('ManagedByOwner', all_projects[2].content_permissions) self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[2].parent_id) + self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[2].owner_id) def test_get_before_signin(self): self.server._auth_token = None @@ -156,7 +159,7 @@ def test_populate_workbooks(self): m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', text=response_xml) single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') - single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_project._owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.projects.populate_workbook_default_permissions(single_project) @@ -227,7 +230,7 @@ def test_delete_workbook_default_permission(self): single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') - single_project.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_project._owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.projects.populate_workbook_default_permissions(single_project) diff --git a/test/test_project_model.py b/test/test_project_model.py index 56e6c3d11..55cf20b26 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -22,3 +22,8 @@ def test_parent_id(self): project = TSC.ProjectItem("proj") project.parent_id = "foo" self.assertEqual(project.parent_id, "foo") + + def test_owner_id(self): + project = TSC.ProjectItem("proj") + with self.assertRaises(NotImplementedError): + project.owner_id = "new_owner" From 857199bba6c4d1c4b40199e02321707f8f0ea638 Mon Sep 17 00:00:00 2001 From: tjones-commits <70481977+tjones-commits@users.noreply.github.com> Date: Fri, 5 Feb 2021 17:09:32 -0500 Subject: [PATCH 219/567] Update site properties and functions (#777) * Update publish_workbook.py (#694) * Update publish_workbook.py Added below arguments, without this there is a sign-in error on publishing a test file to Tableau Online parser.add_argument('--sitename', '-S', default='', help='sitename required') tableau_auth = TSC.TableauAuth(args.username, password,site_id=args.sitename) * Update publish_workbook.py Edits (as requested) to publish workbooks on Tableau Online which removes the Sign-in Error. * Update publish_workbook.py * Merge pull request #745 from tableau/fix_732 Server versions before 2020.1 do not accept encoded query param delimiters * Merge pull request #757 from tableau/fix_754 Fixes issue #754 by moving file read logic inside generator * Updates changelog for v0.14.1 * update the site item to reflect api response * update test model * remove extra test assets * trimming line length * unit test all properties. fix some properties. Remove extra code * make requested changes * make requested changes Co-authored-by: Chris Shin Co-authored-by: Madhura Selvarajan Co-authored-by: Terrence Jones --- CHANGELOG.md | 4 + tableauserverclient/models/site_item.py | 532 +++++++++++++++++- tableauserverclient/server/request_factory.py | 154 ++++- test/assets/site_create.xml | 2 +- test/assets/site_get.xml | 4 +- test/assets/site_get_by_id.xml | 2 +- test/assets/site_get_by_name.xml | 3 +- test/assets/site_update.xml | 2 +- test/test_site.py | 54 +- 9 files changed, 735 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6be649b..85dc8a702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.14.1 (9 Dec 2020) +* Fixed filter query issue for server version below 2020.1 (#745) +* Fixed large workbook/datasource publish issue (#757) + ## 0.14.0 (6 Nov 2020) * Added django-style filtering and sorting (#615) * Added encoding tag-name before deleting (#687) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 1ba854e72..f562289ce 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -17,7 +17,19 @@ class State: def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, data_acceleration_mode=None, flows_enabled=None, cataloging_enabled=None): + revision_limit=None, data_acceleration_mode=None, flows_enabled=True, cataloging_enabled=True, + editing_flows_enabled=True, scheduling_flows_enabled=True, allow_subscription_attachments=True, + guest_access_enabled=False, cache_warmup_enabled=True, commenting_enabled=True, + extract_encryption_mode=None, request_access_enabled=False, run_now_enabled=True, + tier_explorer_capacity=None, tier_creator_capacity=None, tier_viewer_capacity=None, + data_alerts_enabled=True, commenting_mentions_enabled=True, catalog_obfuscation_enabled=False, + flow_auto_save_enabled=True, web_extraction_enabled=True, metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, authoring_enabled=True, custom_subscription_email_enabled=False, + custom_subscription_email=False, custom_subscription_footer_enabled=False, + custom_subscription_footer=False, ask_data_mode='EnabledByDefault', named_sharing_enabled=True, + mobile_biometrics_enabled=False, sheet_image_enabled=True, derived_permissions_enabled=False, + user_visibility_mode='FULL', use_default_time_zone=True, time_zone=None, + auto_suspend_refresh_enabled=True, auto_suspend_refresh_inactivity_window=30): self._admin_mode = None self._id = None self._num_users = None @@ -36,6 +48,40 @@ def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_ self.data_acceleration_mode = data_acceleration_mode self.cataloging_enabled = cataloging_enabled self.flows_enabled = flows_enabled + self.editing_flows_enabled = editing_flows_enabled + self.scheduling_flows_enabled = scheduling_flows_enabled + self.allow_subscription_attachments = allow_subscription_attachments + self.guest_access_enabled = guest_access_enabled + self.cache_warmup_enabled = cache_warmup_enabled + self.commenting_enabled = commenting_enabled + self.extract_encryption_mode = extract_encryption_mode + self.request_access_enabled = request_access_enabled + self.run_now_enabled = run_now_enabled + self.tier_explorer_capacity = tier_explorer_capacity + self.tier_creator_capacity = tier_creator_capacity + self.tier_viewer_capacity = tier_viewer_capacity + self.data_alerts_enabled = data_alerts_enabled + self.commenting_mentions_enabled = commenting_mentions_enabled + self.catalog_obfuscation_enabled = catalog_obfuscation_enabled + self.flow_auto_save_enabled = flow_auto_save_enabled + self.web_extraction_enabled = web_extraction_enabled + self.metrics_content_type_enabled = metrics_content_type_enabled + self.notify_site_admins_on_throttle = notify_site_admins_on_throttle + self.authoring_enabled = authoring_enabled + self.custom_subscription_footer_enabled = custom_subscription_footer_enabled + self.custom_subscription_email_enabled = custom_subscription_email_enabled + self.custom_subscription_email = custom_subscription_email + self.custom_subscription_footer = custom_subscription_footer + self.ask_data_mode = ask_data_mode + self.named_sharing_enabled = named_sharing_enabled + self.mobile_biometrics_enabled = mobile_biometrics_enabled + self.sheet_image_enabled = sheet_image_enabled + self.derived_permissions_enabled = derived_permissions_enabled + self.user_visibility_mode = user_visibility_mode + self.use_default_time_zone = use_default_time_zone + self.time_zone = time_zone + self.auto_suspend_refresh_enabled = auto_suspend_refresh_enabled + self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @property def admin_mode(self): @@ -147,12 +193,307 @@ def flows_enabled(self): return self._flows_enabled @flows_enabled.setter + @property_is_boolean def flows_enabled(self, value): self._flows_enabled = value def is_default(self): return self.name.lower() == 'default' + @property + def editing_flows_enabled(self): + return self._editing_flows_enabled + + @editing_flows_enabled.setter + @property_is_boolean + def editing_flows_enabled(self, value): + self._editing_flows_enabled = value + + @property + def scheduling_flows_enabled(self): + return self._scheduling_flows_enabled + + @scheduling_flows_enabled.setter + @property_is_boolean + def scheduling_flows_enabled(self, value): + self._scheduling_flows_enabled = value + + @property + def allow_subscription_attachments(self): + return self._allow_subscription_attachments + + @allow_subscription_attachments.setter + @property_is_boolean + def allow_subscription_attachments(self, value): + self._allow_subscription_attachments = value + + @property + def guest_access_enabled(self): + return self._guest_access_enabled + + @guest_access_enabled.setter + @property_is_boolean + def guest_access_enabled(self, value): + self._guest_access_enabled = value + + @property + def cache_warmup_enabled(self): + return self._cache_warmup_enabled + + @cache_warmup_enabled.setter + @property_is_boolean + def cache_warmup_enabled(self, value): + self._cache_warmup_enabled = value + + @property + def commenting_enabled(self): + return self._commenting_enabled + + @commenting_enabled.setter + @property_is_boolean + def commenting_enabled(self, value): + self._commenting_enabled = value + + @property + def extract_encryption_mode(self): + return self._extract_encryption_mode + + @extract_encryption_mode.setter + def extract_encryption_mode(self, value): + self._extract_encryption_mode = value + + @property + def request_access_enabled(self): + return self._request_access_enabled + + @request_access_enabled.setter + @property_is_boolean + def request_access_enabled(self, value): + self._request_access_enabled = value + + @property + def run_now_enabled(self): + return self._run_now_enabled + + @run_now_enabled.setter + @property_is_boolean + def run_now_enabled(self, value): + self._run_now_enabled = value + + @property + def tier_explorer_capacity(self): + return self._tier_explorer_capacity + + @tier_explorer_capacity.setter + def tier_explorer_capacity(self, value): + self._tier_explorer_capacity = value + + @property + def tier_creator_capacity(self): + return self._tier_creator_capacity + + @tier_creator_capacity.setter + def tier_creator_capacity(self, value): + self._tier_creator_capacity = value + + @property + def tier_viewer_capacity(self): + return self._tier_viewer_capacity + + @tier_viewer_capacity.setter + def tier_viewer_capacity(self, value): + self._tier_viewer_capacity = value + + @property + def data_alerts_enabled(self): + return self._data_alerts_enabled + + @data_alerts_enabled.setter + @property_is_boolean + def data_alerts_enabled(self, value): + self._data_alerts_enabled = value + + @property + def commenting_mentions_enabled(self): + return self._commenting_mentions_enabled + + @commenting_mentions_enabled.setter + @property_is_boolean + def commenting_mentions_enabled(self, value): + self._commenting_mentions_enabled = value + + @property + def catalog_obfuscation_enabled(self): + return self._catalog_obfuscation_enabled + + @catalog_obfuscation_enabled.setter + @property_is_boolean + def catalog_obfuscation_enabled(self, value): + self._catalog_obfuscation_enabled = value + + @property + def flow_auto_save_enabled(self): + return self._flow_auto_save_enabled + + @flow_auto_save_enabled.setter + @property_is_boolean + def flow_auto_save_enabled(self, value): + self._flow_auto_save_enabled = value + + @property + def web_extraction_enabled(self): + return self._web_extraction_enabled + + @web_extraction_enabled.setter + @property_is_boolean + def web_extraction_enabled(self, value): + self._web_extraction_enabled = value + + @property + def metrics_content_type_enabled(self): + return self._metrics_content_type_enabled + + @metrics_content_type_enabled.setter + @property_is_boolean + def metrics_content_type_enabled(self, value): + self._metrics_content_type_enabled = value + + @property + def notify_site_admins_on_throttle(self): + return self._notify_site_admins_on_throttle + + @notify_site_admins_on_throttle.setter + @property_is_boolean + def notify_site_admins_on_throttle(self, value): + self._notify_site_admins_on_throttle = value + + @property + def authoring_enabled(self): + return self._authoring_enabled + + @authoring_enabled.setter + @property_is_boolean + def authoring_enabled(self, value): + self._authoring_enabled = value + + @property + def custom_subscription_email_enabled(self): + return self._custom_subscription_email_enabled + + @custom_subscription_email_enabled.setter + @property_is_boolean + def custom_subscription_email_enabled(self, value): + self._custom_subscription_email_enabled = value + + @property + def custom_subscription_email(self): + return self._custom_subscription_email + + @custom_subscription_email.setter + def custom_subscription_email(self, value): + self._custom_subscription_email = value + + @property + def custom_subscription_footer_enabled(self): + return self._custom_subscription_footer_enabled + + @custom_subscription_footer_enabled.setter + @property_is_boolean + def custom_subscription_footer_enabled(self, value): + self._custom_subscription_footer_enabled = value + + @property + def custom_subscription_footer(self): + return self._custom_subscription_footer + + @custom_subscription_footer.setter + def custom_subscription_footer(self, value): + self._custom_subscription_footer = value + + @property + def ask_data_mode(self): + return self._ask_data_mode + + @ask_data_mode.setter + def ask_data_mode(self, value): + self._ask_data_mode = value + + @property + def named_sharing_enabled(self): + return self._named_sharing_enabled + + @named_sharing_enabled.setter + @property_is_boolean + def named_sharing_enabled(self, value): + self._named_sharing_enabled = value + + @property + def mobile_biometrics_enabled(self): + return self._mobile_biometrics_enabled + + @mobile_biometrics_enabled.setter + @property_is_boolean + def mobile_biometrics_enabled(self, value): + self._mobile_biometrics_enabled = value + + @property + def sheet_image_enabled(self): + return self._sheet_image_enabled + + @sheet_image_enabled.setter + @property_is_boolean + def sheet_image_enabled(self, value): + self._sheet_image_enabled = value + + @property + def derived_permissions_enabled(self): + return self._derived_permissions_enabled + + @derived_permissions_enabled.setter + @property_is_boolean + def derived_permissions_enabled(self, value): + self._derived_permissions_enabled = value + + @property + def user_visibility_mode(self): + return self._user_visibility_mode + + @user_visibility_mode.setter + def user_visibility_mode(self, value): + self._user_visibility_mode = value + + @property + def use_default_time_zone(self): + return self._use_default_time_zone + + @use_default_time_zone.setter + def use_default_time_zone(self, value): + self._use_default_time_zone = value + + @property + def time_zone(self): + return self._time_zone + + @time_zone.setter + def time_zone(self, value): + self._time_zone = value + + @property + def auto_suspend_refresh_inactivity_window(self): + return self._auto_suspend_refresh_inactivity_window + + @auto_suspend_refresh_inactivity_window.setter + def auto_suspend_refresh_inactivity_window(self, value): + self._auto_suspend_refresh_inactivity_window = value + + @property + def auto_suspend_refresh_enabled(self): + return self._auto_suspend_refresh_enabled + + @auto_suspend_refresh_enabled.setter + def auto_suspend_refresh_enabled(self, value): + self._auto_suspend_refresh_enabled = value + def _parse_common_tags(self, site_xml, ns): if not isinstance(site_xml, ET.Element): site_xml = ET.fromstring(site_xml).find('.//t:site', namespaces=ns) @@ -160,18 +501,46 @@ def _parse_common_tags(self, site_xml, ns): (_, name, content_url, _, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, - data_acceleration_mode, cataloging_enabled, flows_enabled) = self._parse_element(site_xml, ns) + data_acceleration_mode, flows_enabled, cataloging_enabled, editing_flows_enabled, + scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled, + cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, + run_now_enabled, tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, + commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled, + metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled, + custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled, + custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled, + sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone, + auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window) = self._parse_element(site_xml, ns) self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, data_acceleration_mode, cataloging_enabled, - flows_enabled) + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, + cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, + allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, + commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled, + tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, + commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, + web_extraction_enabled, metrics_content_type_enabled, notify_site_admins_on_throttle, + authoring_enabled, custom_subscription_email_enabled, custom_subscription_email, + custom_subscription_footer_enabled, custom_subscription_footer, ask_data_mode, + named_sharing_enabled, mobile_biometrics_enabled, sheet_image_enabled, + derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone, + auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window) return self def _set_values(self, id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, revision_limit, num_users, storage, data_acceleration_mode, - flows_enabled, cataloging_enabled): + flows_enabled, cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, + allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, commenting_enabled, + extract_encryption_mode, request_access_enabled, run_now_enabled, tier_explorer_capacity, + tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, commenting_mentions_enabled, + catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled, + metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled, + custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled, + custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled, + sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, + time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window): if id is not None: self._id = id if name: @@ -206,6 +575,74 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, self.flows_enabled = flows_enabled if cataloging_enabled is not None: self.cataloging_enabled = cataloging_enabled + if editing_flows_enabled is not None: + self.editing_flows_enabled = editing_flows_enabled + if scheduling_flows_enabled is not None: + self.scheduling_flows_enabled = scheduling_flows_enabled + if allow_subscription_attachments is not None: + self.allow_subscription_attachments = allow_subscription_attachments + if guest_access_enabled is not None: + self.guest_access_enabled = guest_access_enabled + if cache_warmup_enabled is not None: + self.cache_warmup_enabled = cache_warmup_enabled + if commenting_enabled is not None: + self.commenting_enabled = commenting_enabled + if extract_encryption_mode is not None: + self.extract_encryption_mode = extract_encryption_mode + if request_access_enabled is not None: + self.request_access_enabled = request_access_enabled + if run_now_enabled is not None: + self.run_now_enabled = run_now_enabled + if tier_explorer_capacity: + self.tier_explorer_capacity = tier_explorer_capacity + if tier_creator_capacity: + self.tier_creator_capacity = tier_creator_capacity + if tier_viewer_capacity: + self.tier_viewer_capacity = tier_viewer_capacity + if data_alerts_enabled is not None: + self.data_alerts_enabled = data_alerts_enabled + if commenting_mentions_enabled is not None: + self.commenting_mentions_enabled = commenting_mentions_enabled + if catalog_obfuscation_enabled is not None: + self.catalog_obfuscation_enabled = catalog_obfuscation_enabled + if flow_auto_save_enabled is not None: + self.flow_auto_save_enabled = flow_auto_save_enabled + if web_extraction_enabled is not None: + self.web_extraction_enabled = web_extraction_enabled + if metrics_content_type_enabled is not None: + self.metrics_content_type_enabled = metrics_content_type_enabled + if notify_site_admins_on_throttle is not None: + self.notify_site_admins_on_throttle = notify_site_admins_on_throttle + if authoring_enabled is not None: + self.authoring_enabled = authoring_enabled + if custom_subscription_email_enabled is not None: + self.custom_subscription_email_enabled = custom_subscription_email_enabled + if custom_subscription_email is not None: + self.custom_subscription_email = custom_subscription_email + if custom_subscription_footer_enabled is not None: + self.custom_subscription_footer_enabled = custom_subscription_footer_enabled + if custom_subscription_footer is not None: + self.custom_subscription_footer = custom_subscription_footer + if ask_data_mode is not None: + self.ask_data_mode = ask_data_mode + if named_sharing_enabled is not None: + self.named_sharing_enabled = named_sharing_enabled + if mobile_biometrics_enabled is not None: + self.mobile_biometrics_enabled = mobile_biometrics_enabled + if sheet_image_enabled is not None: + self.sheet_image_enabled = sheet_image_enabled + if derived_permissions_enabled is not None: + self.derived_permissions_enabled = derived_permissions_enabled + if user_visibility_mode is not None: + self.user_visibility_mode = user_visibility_mode + if use_default_time_zone is not None: + self.use_default_time_zone = use_default_time_zone + if time_zone is not None: + self.time_zone = time_zone + if auto_suspend_refresh_enabled is not None: + self.auto_suspend_refresh_enabled = auto_suspend_refresh_enabled + if auto_suspend_refresh_inactivity_window is not None: + self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod def from_response(cls, resp, ns): @@ -215,14 +652,34 @@ def from_response(cls, resp, ns): for site_xml in all_site_xml: (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, - cataloging_enabled) = cls._parse_element(site_xml, ns) + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled, + editing_flows_enabled, scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled, + cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, + run_now_enabled, tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, + data_alerts_enabled, commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, + web_extraction_enabled, metrics_content_type_enabled, notify_site_admins_on_throttle, + authoring_enabled, custom_subscription_email_enabled, custom_subscription_email, + custom_subscription_footer_enabled, custom_subscription_footer, ask_data_mode, named_sharing_enabled, + mobile_biometrics_enabled, sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, + use_default_time_zone, time_zone, auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) - site_item._set_values(id, name, content_url, status_reason, admin_mode, state, - subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, - data_acceleration_mode, flows_enabled, cataloging_enabled) + site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, + disable_subscriptions, revision_history_enabled, user_quota, storage_quota, + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, + cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, + allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, + commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled, + tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, + data_alerts_enabled, commenting_mentions_enabled, catalog_obfuscation_enabled, + flow_auto_save_enabled, web_extraction_enabled, metrics_content_type_enabled, + notify_site_admins_on_throttle, authoring_enabled, custom_subscription_email_enabled, + custom_subscription_email, custom_subscription_footer_enabled, + custom_subscription_footer, ask_data_mode, named_sharing_enabled, + mobile_biometrics_enabled, sheet_image_enabled, derived_permissions_enabled, + user_visibility_mode, use_default_time_zone, time_zone, auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window) all_site_items.append(site_item) return all_site_items @@ -237,6 +694,48 @@ def _parse_element(site_xml, ns): subscribe_others_enabled = string_to_bool(site_xml.get('subscribeOthersEnabled', '')) disable_subscriptions = string_to_bool(site_xml.get('disableSubscriptions', '')) revision_history_enabled = string_to_bool(site_xml.get('revisionHistoryEnabled', '')) + editing_flows_enabled = string_to_bool(site_xml.get('editingFlowsEnabled', '')) + scheduling_flows_enabled = string_to_bool(site_xml.get('schedulingFlowsEnabled', '')) + allow_subscription_attachments = string_to_bool(site_xml.get('allowSubscriptionAttachments', '')) + guest_access_enabled = string_to_bool(site_xml.get('guestAccessEnabled', '')) + cache_warmup_enabled = string_to_bool(site_xml.get('cacheWarmupEnabled', '')) + commenting_enabled = string_to_bool(site_xml.get('commentingEnabled', '')) + extract_encryption_mode = site_xml.get('extractEncryptionMode', None) + request_access_enabled = string_to_bool(site_xml.get('requestAccessEnabled', '')) + run_now_enabled = string_to_bool(site_xml.get('runNowEnabled', '')) + tier_explorer_capacity = site_xml.get('tierExplorerCapacity', None) + if tier_explorer_capacity: + tier_explorer_capacity = int(tier_explorer_capacity) + tier_creator_capacity = site_xml.get('tierCreatorCapacity', None) + if tier_creator_capacity: + tier_creator_capacity = int(tier_creator_capacity) + tier_viewer_capacity = site_xml.get('tierViewerCapacity', None) + if tier_viewer_capacity: + tier_viewer_capacity = int(tier_viewer_capacity) + data_alerts_enabled = string_to_bool(site_xml.get('dataAlertsEnabled', '')) + commenting_mentions_enabled = string_to_bool(site_xml.get('commentingMentionsEnabled', '')) + catalog_obfuscation_enabled = string_to_bool(site_xml.get('catalogObfuscationEnabled', '')) + flow_auto_save_enabled = string_to_bool(site_xml.get('flowAutoSaveEnabled', '')) + web_extraction_enabled = string_to_bool(site_xml.get('webExtractionEnabled', '')) + metrics_content_type_enabled = string_to_bool(site_xml.get('metricsContentTypeEnabled', '')) + notify_site_admins_on_throttle = string_to_bool(site_xml.get('notifySiteAdminsOnThrottle', '')) + authoring_enabled = string_to_bool(site_xml.get('authoringEnabled', '')) + custom_subscription_email_enabled = string_to_bool(site_xml.get('customSubscriptionEmailEnabled', '')) + custom_subscription_email = site_xml.get('customSubscriptionEmail', None) + custom_subscription_footer_enabled = string_to_bool(site_xml.get('customSubscriptionFooterEnabled', '')) + custom_subscription_footer = site_xml.get('customSubscriptionFooter', None) + ask_data_mode = site_xml.get('askDataMode', None) + named_sharing_enabled = string_to_bool(site_xml.get('namedSharingEnabled', '')) + mobile_biometrics_enabled = string_to_bool(site_xml.get('mobileBiometricsEnabled', '')) + sheet_image_enabled = string_to_bool(site_xml.get('sheetImageEnabled', '')) + derived_permissions_enabled = string_to_bool(site_xml.get('derivedPermissionsEnabled', '')) + user_visibility_mode = site_xml.get('userVisibilityMode', '') + use_default_time_zone = string_to_bool(site_xml.get('useDefaultTimeZone', '')) + time_zone = site_xml.get('timeZone', None) + auto_suspend_refresh_enabled = string_to_bool(site_xml.get('autoSuspendRefreshEnabled', '')) + auto_suspend_refresh_inactivity_window = site_xml.get('autoSuspendRefreshInactivityWindow', None) + if auto_suspend_refresh_inactivity_window: + auto_suspend_refresh_inactivity_window = int(auto_suspend_refresh_inactivity_window) user_quota = site_xml.get('userQuota', None) if user_quota: @@ -264,7 +763,16 @@ def _parse_element(site_xml, ns): return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled + revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled,\ + editing_flows_enabled, scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled,\ + cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled,\ + tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled,\ + commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled,\ + metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled,\ + custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled,\ + custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled,\ + sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone,\ + auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window # Used to convert string represented boolean to a boolean type diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 14c755bbd..84e7afe0f 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -472,7 +472,7 @@ def update_req(self, site_item): site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: site_element.attrib['revisionLimit'] = str(site_item.revision_limit) - if site_item.subscribe_others_enabled is not None: + if site_item.revision_history_enabled is not None: site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() if site_item.data_acceleration_mode is not None: site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() @@ -480,6 +480,78 @@ def update_req(self, site_item): site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() if site_item.cataloging_enabled is not None: site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() + if site_item.editing_flows_enabled is not None: + site_element.attrib['editingFlowsEnabled'] = str(site_item.editing_flows_enabled).lower() + if site_item.scheduling_flows_enabled is not None: + site_element.attrib['schedulingFlowsEnabled'] = str(site_item.scheduling_flows_enabled).lower() + if site_item.allow_subscription_attachments is not None: + site_element.attrib['allowSubscriptionAttachments'] = str(site_item.allow_subscription_attachments).lower() + if site_item.guest_access_enabled is not None: + site_element.attrib['guestAccessEnabled'] = str(site_item.guest_access_enabled).lower() + if site_item.cache_warmup_enabled is not None: + site_element.attrib['cacheWarmupEnabled'] = str(site_item.cache_warmup_enabled).lower() + if site_item.commenting_enabled is not None: + site_element.attrib['commentingEnabled'] = str(site_item.commenting_enabled).lower() + if site_item.extract_encryption_mode is not None: + site_element.attrib['extractEncryptionMode'] = str(site_item.extract_encryption_mode).lower() + if site_item.request_access_enabled is not None: + site_element.attrib['requestAccessEnabled'] = str(site_item.request_access_enabled).lower() + if site_item.run_now_enabled is not None: + site_element.attrib['runNowEnabled'] = str(site_item.run_now_enabled).lower() + if site_item.tier_creator_capacity is not None: + site_element.attrib['tierCreatorCapacity'] = str(site_item.tier_creator_capacity).lower() + if site_item.tier_explorer_capacity is not None: + site_element.attrib['tierExplorerCapacity'] = str(site_item.tier_explorer_capacity).lower() + if site_item.tier_viewer_capacity is not None: + site_element.attrib['tierViewerCapacity'] = str(site_item.tier_viewer_capacity).lower() + if site_item.data_alerts_enabled is not None: + site_element.attrib['dataAlertsEnabled'] = str(site_item.data_alerts_enabled) + if site_item.commenting_mentions_enabled is not None: + site_element.attrib['commentingMentionsEnabled'] = str(site_item.commenting_mentions_enabled).lower() + if site_item.catalog_obfuscation_enabled is not None: + site_element.attrib['catalogObfuscationEnabled'] = str(site_item.catalog_obfuscation_enabled).lower() + if site_item.flow_auto_save_enabled is not None: + site_element.attrib['flowAutoSaveEnabled'] = str(site_item.flow_auto_save_enabled).lower() + if site_item.web_extraction_enabled is not None: + site_element.attrib['webExtractionEnabled'] = str(site_item.web_extraction_enabled).lower() + if site_item.metrics_content_type_enabled is not None: + site_element.attrib['metricsContentTypeEnabled'] = str(site_item.metrics_content_type_enabled).lower() + if site_item.notify_site_admins_on_throttle is not None: + site_element.attrib['notifySiteAdminsOnThrottle'] = str(site_item.notify_site_admins_on_throttle).lower() + if site_item.authoring_enabled is not None: + site_element.attrib['authoringEnabled'] = str(site_item.authoring_enabled).lower() + if site_item.custom_subscription_email_enabled is not None: + site_element.attrib['customSubscriptionEmailEnabled'] = \ + str(site_item.custom_subscription_email_enabled).lower() + if site_item.custom_subscription_email is not None: + site_element.attrib['customSubscriptionEmail'] = str(site_item.custom_subscription_email).lower() + if site_item.custom_subscription_footer_enabled is not None: + site_element.attrib['customSubscriptionFooterEnabled'] =\ + str(site_item.custom_subscription_footer_enabled).lower() + if site_item.custom_subscription_footer is not None: + site_element.attrib['customSubscriptionFooter'] = str(site_item.custom_subscription_footer).lower() + if site_item.ask_data_mode is not None: + site_element.attrib['askDataMode'] = str(site_item.ask_data_mode) + if site_item.named_sharing_enabled is not None: + site_element.attrib['namedSharingEnabled'] = str(site_item.named_sharing_enabled).lower() + if site_item.mobile_biometrics_enabled is not None: + site_element.attrib['mobileBiometricsEnabled'] = str(site_item.mobile_biometrics_enabled).lower() + if site_item.sheet_image_enabled is not None: + site_element.attrib['sheetImageEnabled'] = str(site_item.sheet_image_enabled).lower() + if site_item.derived_permissions_enabled is not None: + site_element.attrib['derivedPermissionsEnabled'] = str(site_item.derived_permissions_enabled).lower() + if site_item.user_visibility_mode is not None: + site_element.attrib['userVisibilityMode'] = str(site_item.user_visibility_mode) + if site_item.use_default_time_zone is not None: + site_element.attrib['useDefaultTimeZone'] = str(site_item.use_default_time_zone).lower() + if site_item.time_zone is not None: + site_element.attrib['timeZone'] = str(site_item.time_zone) + if site_item.auto_suspend_refresh_enabled is not None: + site_element.attrib['autoSuspendRefreshEnabled'] = str(site_item.auto_suspend_refresh_enabled).lower() + if site_item.auto_suspend_refresh_inactivity_window is not None: + site_element.attrib['autoSuspendRefreshInactivityWindow'] =\ + str(site_item.auto_suspend_refresh_inactivity_window) + return ET.tostring(xml_request) def create_req(self, site_item): @@ -495,10 +567,90 @@ def create_req(self, site_item): site_element.attrib['storageQuota'] = str(site_item.storage_quota) if site_item.disable_subscriptions is not None: site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() + if site_item.subscribe_others_enabled is not None: + site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() + if site_item.revision_limit: + site_element.attrib['revisionLimit'] = str(site_item.revision_limit) + if site_item.data_acceleration_mode is not None: + site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() if site_item.flows_enabled is not None: site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + if site_item.editing_flows_enabled is not None: + site_element.attrib['editingFlowsEnabled'] = str(site_item.editing_flows_enabled).lower() + if site_item.scheduling_flows_enabled is not None: + site_element.attrib['schedulingFlowsEnabled'] = str(site_item.scheduling_flows_enabled).lower() + if site_item.allow_subscription_attachments is not None: + site_element.attrib['allowSubscriptionAttachments'] = str(site_item.allow_subscription_attachments).lower() + if site_item.guest_access_enabled is not None: + site_element.attrib['guestAccessEnabled'] = str(site_item.guest_access_enabled).lower() + if site_item.cache_warmup_enabled is not None: + site_element.attrib['cacheWarmupEnabled'] = str(site_item.cache_warmup_enabled).lower() + if site_item.commenting_enabled is not None: + site_element.attrib['commentingEnabled'] = str(site_item.commenting_enabled).lower() + if site_item.revision_history_enabled is not None: + site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() + if site_item.extract_encryption_mode is not None: + site_element.attrib['extractEncryptionMode'] = str(site_item.extract_encryption_mode).lower() + if site_item.request_access_enabled is not None: + site_element.attrib['requestAccessEnabled'] = str(site_item.request_access_enabled).lower() + if site_item.run_now_enabled is not None: + site_element.attrib['runNowEnabled'] = str(site_item.run_now_enabled).lower() + if site_item.tier_creator_capacity is not None: + site_element.attrib['tierCreatorCapacity'] = str(site_item.tier_creator_capacity).lower() + if site_item.tier_explorer_capacity is not None: + site_element.attrib['tierExplorerCapacity'] = str(site_item.tier_explorer_capacity).lower() + if site_item.tier_viewer_capacity is not None: + site_element.attrib['tierViewerCapacity'] = str(site_item.tier_viewer_capacity).lower() + if site_item.data_alerts_enabled is not None: + site_element.attrib['dataAlertsEnabled'] = str(site_item.data_alerts_enabled).lower() + if site_item.commenting_mentions_enabled is not None: + site_element.attrib['commentingMentionsEnabled'] = str(site_item.commenting_mentions_enabled).lower() + if site_item.catalog_obfuscation_enabled is not None: + site_element.attrib['catalogObfuscationEnabled'] = str(site_item.catalog_obfuscation_enabled).lower() + if site_item.flow_auto_save_enabled is not None: + site_element.attrib['flowAutoSaveEnabled'] = str(site_item.flow_auto_save_enabled).lower() + if site_item.web_extraction_enabled is not None: + site_element.attrib['webExtractionEnabled'] = str(site_item.web_extraction_enabled).lower() + if site_item.metrics_content_type_enabled is not None: + site_element.attrib['metricsContentTypeEnabled'] = str(site_item.metrics_content_type_enabled).lower() + if site_item.notify_site_admins_on_throttle is not None: + site_element.attrib['notifySiteAdminsOnThrottle'] = str(site_item.notify_site_admins_on_throttle).lower() + if site_item.authoring_enabled is not None: + site_element.attrib['authoringEnabled'] = str(site_item.authoring_enabled).lower() + if site_item.custom_subscription_email_enabled is not None: + site_element.attrib['customSubscriptionEmailEnabled'] =\ + str(site_item.custom_subscription_email_enabled).lower() + if site_item.custom_subscription_email is not None: + site_element.attrib['customSubscriptionEmail'] = str(site_item.custom_subscription_email).lower() + if site_item.custom_subscription_footer_enabled is not None: + site_element.attrib['customSubscriptionFooterEnabled'] =\ + str(site_item.custom_subscription_footer_enabled).lower() + if site_item.custom_subscription_footer is not None: + site_element.attrib['customSubscriptionFooter'] = str(site_item.custom_subscription_footer).lower() + if site_item.ask_data_mode is not None: + site_element.attrib['askDataMode'] = str(site_item.ask_data_mode) + if site_item.named_sharing_enabled is not None: + site_element.attrib['namedSharingEnabled'] = str(site_item.named_sharing_enabled).lower() + if site_item.mobile_biometrics_enabled is not None: + site_element.attrib['mobileBiometricsEnabled'] = str(site_item.mobile_biometrics_enabled).lower() + if site_item.sheet_image_enabled is not None: + site_element.attrib['sheetImageEnabled'] = str(site_item.sheet_image_enabled).lower() if site_item.cataloging_enabled is not None: site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() + if site_item.derived_permissions_enabled is not None: + site_element.attrib['derivedPermissionsEnabled'] = str(site_item.derived_permissions_enabled).lower() + if site_item.user_visibility_mode is not None: + site_element.attrib['userVisibilityMode'] = str(site_item.user_visibility_mode) + if site_item.use_default_time_zone is not None: + site_element.attrib['useDefaultTimeZone'] = str(site_item.use_default_time_zone).lower() + if site_item.time_zone is not None: + site_element.attrib['timeZone'] = str(site_item.time_zone) + if site_item.auto_suspend_refresh_enabled is not None: + site_element.attrib['autoSuspendRefreshEnabled'] = str(site_item.auto_suspend_refresh_enabled).lower() + if site_item.auto_suspend_refresh_inactivity_window is not None: + site_element.attrib['autoSuspendRefreshInactivityWindow'] =\ + str(site_item.auto_suspend_refresh_inactivity_window) + return ET.tostring(xml_request) diff --git a/test/assets/site_create.xml b/test/assets/site_create.xml index 9fafb5f02..9d9c4a009 100644 --- a/test/assets/site_create.xml +++ b/test/assets/site_create.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/site_get.xml b/test/assets/site_get.xml index e3c7a781c..7ffa91eb7 100644 --- a/test/assets/site_get.xml +++ b/test/assets/site_get.xml @@ -2,7 +2,7 @@ - - + + \ No newline at end of file diff --git a/test/assets/site_get_by_id.xml b/test/assets/site_get_by_id.xml index 98bc3e4e6..a47703fb6 100644 --- a/test/assets/site_get_by_id.xml +++ b/test/assets/site_get_by_id.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/site_get_by_name.xml b/test/assets/site_get_by_name.xml index 5b3042e61..852f9594f 100644 --- a/test/assets/site_get_by_name.xml +++ b/test/assets/site_get_by_name.xml @@ -1,5 +1,4 @@ - + \ No newline at end of file diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index 30e434373..dbb166de1 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/test/test_site.py b/test/test_site.py index a06876e2a..8fbb4eda3 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -36,13 +36,30 @@ def test_get(self): self.assertEqual('ContentOnly', all_sites[0].admin_mode) self.assertEqual(False, all_sites[0].revision_history_enabled) self.assertEqual(True, all_sites[0].subscribe_others_enabled) - + self.assertEqual(25, all_sites[0].revision_limit) + self.assertEqual(None, all_sites[0].num_users) + self.assertEqual(None, all_sites[0].storage) + self.assertEqual(True, all_sites[0].cataloging_enabled) + self.assertEqual(False, all_sites[0].editing_flows_enabled) + self.assertEqual(False, all_sites[0].scheduling_flows_enabled) + self.assertEqual(True, all_sites[0].allow_subscription_attachments) self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', all_sites[1].id) self.assertEqual('Active', all_sites[1].state) self.assertEqual('Samples', all_sites[1].name) self.assertEqual('ContentOnly', all_sites[1].admin_mode) self.assertEqual(False, all_sites[1].revision_history_enabled) self.assertEqual(True, all_sites[1].subscribe_others_enabled) + self.assertEqual(False, all_sites[1].guest_access_enabled) + self.assertEqual(True, all_sites[1].cache_warmup_enabled) + self.assertEqual(True, all_sites[1].commenting_enabled) + self.assertEqual(True, all_sites[1].cache_warmup_enabled) + self.assertEqual(False, all_sites[1].request_access_enabled) + self.assertEqual(True, all_sites[1].run_now_enabled) + self.assertEqual(1, all_sites[1].tier_explorer_capacity) + self.assertEqual(2, all_sites[1].tier_creator_capacity) + self.assertEqual(1, all_sites[1].tier_viewer_capacity) + self.assertEqual(False, all_sites[1].flows_enabled) + self.assertEqual(None, all_sites[1].data_acceleration_mode) def test_get_before_signin(self): self.server._auth_token = None @@ -62,6 +79,9 @@ def test_get_by_id(self): self.assertEqual(False, single_site.revision_history_enabled) self.assertEqual(True, single_site.subscribe_others_enabled) self.assertEqual(False, single_site.disable_subscriptions) + self.assertEqual(False, single_site.data_alerts_enabled) + self.assertEqual(False, single_site.commenting_mentions_enabled) + self.assertEqual(True, single_site.catalog_obfuscation_enabled) def test_get_by_id_missing_id(self): self.assertRaises(ValueError, self.server.sites.get_by_id, '') @@ -93,7 +113,18 @@ def test_update(self): admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, storage_quota=1000, disable_subscriptions=True, revision_history_enabled=False, - data_acceleration_mode='disable') + data_acceleration_mode='disable', flow_auto_save_enabled=True, + web_extraction_enabled=False, metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, authoring_enabled=True, + custom_subscription_email_enabled=True, + custom_subscription_email='test@test.com', + custom_subscription_footer_enabled=True, + custom_subscription_footer='example_footer', ask_data_mode='EnabledByDefault', + named_sharing_enabled=False, mobile_biometrics_enabled=True, + sheet_image_enabled=False, derived_permissions_enabled=True, + user_visibility_mode='FULL', use_default_time_zone=False, + time_zone='America/Los_Angeles', auto_suspend_refresh_enabled=True, + auto_suspend_refresh_inactivity_window=55) single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' single_site = self.server.sites.update(single_site) @@ -109,6 +140,25 @@ def test_update(self): self.assertEqual('disable', single_site.data_acceleration_mode) self.assertEqual(True, single_site.flows_enabled) self.assertEqual(True, single_site.cataloging_enabled) + self.assertEqual(True, single_site.flow_auto_save_enabled) + self.assertEqual(False, single_site.web_extraction_enabled) + self.assertEqual(True, single_site.metrics_content_type_enabled) + self.assertEqual(False, single_site.notify_site_admins_on_throttle) + self.assertEqual(True, single_site.authoring_enabled) + self.assertEqual(True, single_site.custom_subscription_email_enabled) + self.assertEqual('test@test.com', single_site.custom_subscription_email) + self.assertEqual(True, single_site.custom_subscription_footer_enabled) + self.assertEqual('example_footer', single_site.custom_subscription_footer) + self.assertEqual('EnabledByDefault', single_site.ask_data_mode) + self.assertEqual(False, single_site.named_sharing_enabled) + self.assertEqual(True, single_site.mobile_biometrics_enabled) + self.assertEqual(False, single_site.sheet_image_enabled) + self.assertEqual(True, single_site.derived_permissions_enabled) + self.assertEqual('FULL', single_site.user_visibility_mode) + self.assertEqual(False, single_site.use_default_time_zone) + self.assertEqual('America/Los_Angeles', single_site.time_zone) + self.assertEqual(True, single_site.auto_suspend_refresh_enabled) + self.assertEqual(55, single_site.auto_suspend_refresh_inactivity_window) def test_update_missing_id(self): single_site = TSC.SiteItem('test', 'test') From 026bca8dd48f7fea85473599261bb673f6ad43be Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Thu, 11 Feb 2021 15:34:08 -0800 Subject: [PATCH 220/567] Adds skipConnectionCheck to publish workbook (#791) * Adds skipConnectionCheck flag to publish workbook * Removes unnecessary lines * Fixes style error --- samples/publish_workbook.py | 7 ++++-- .../server/endpoint/workbooks_endpoint.py | 5 ++++- test/test_workbook.py | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index be2c9599f..ca366cf9e 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -31,6 +31,7 @@ def main(): parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') + parser.add_argument('--skip-connection-check', '-c', help='Skip live connection check', action='store_true') parser.add_argument('--site', '-S', default='', help='id (contentUrl) of site to sign into') args = parser.parse_args() @@ -71,11 +72,13 @@ def main(): new_workbook = TSC.WorkbookItem(default_project.id) if args.as_job: new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job) + connections=all_connections, as_job=args.as_job, + skip_connection_check=args.skip_connection_check) print("Workbook published. JOB ID: {0}".format(new_job.id)) else: new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job) + connections=all_connections, as_job=args.as_job, + skip_connection_check=args.skip_connection_check) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 62f94f99a..e40d9e1dd 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -256,7 +256,7 @@ def delete_permission(self, item, capability_item): def publish( self, workbook_item, file, mode, connection_credentials=None, connections=None, as_job=False, - hidden_views=None + hidden_views=None, skip_connection_check=False ): if connection_credentials is not None: @@ -318,6 +318,9 @@ def publish( if as_job: url += '&{0}=true'.format('asJob') + if skip_connection_check: + url += '&{0}=true'.format('skipConnectionCheck') + # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(workbook_item.name)) diff --git a/test/test_workbook.py b/test/test_workbook.py index f14e4d96f..fc1344b9e 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -544,6 +544,28 @@ def test_publish_with_hidden_view(self): self.assertTrue(re.search(rb'<\/views>', request_body)) self.assertTrue(re.search(rb'<\/views>', request_body)) + def test_publish_with_query_params(self): + 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) + + new_workbook = TSC.WorkbookItem(name='Sample', + show_tabs=False, + project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + publish_mode = self.server.PublishMode.CreateNew + + self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, + as_job=True, skip_connection_check=True) + + request_query_params = m._adapter.request_history[0].qs + self.assertTrue('asjob' in request_query_params) + self.assertTrue(request_query_params['asjob']) + self.assertTrue('skipconnectioncheck' in request_query_params) + self.assertTrue(request_query_params['skipconnectioncheck']) + def test_publish_async(self): self.server.version = '3.0' baseurl = self.server.workbooks.baseurl From 88a01886b26165bd73bb8c4dd061efa4a3083a44 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 16 Feb 2021 09:17:35 -0800 Subject: [PATCH 221/567] [Subscriptions] Add new fields and ability to update (#794) * Add fields and parsing logic * Update subscription create request * Adds update request to subscriptions * Changes subscription change request creation to use tsrequest annotation * Update tests for parsing new fields * Fixes codestyle issues * Removes user and schedule name * Fixes test failure --- .../models/subscription_item.py | 92 +++++++++++++++++-- .../server/endpoint/subscriptions_endpoint.py | 12 +++ tableauserverclient/server/request_factory.py | 52 ++++++++++- test/assets/subscription_get.xml | 8 +- test/test_subscription.py | 27 +++++- 5 files changed, 172 insertions(+), 19 deletions(-) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 1a93c60d2..cdcc468a1 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,15 +1,23 @@ import xml.etree.ElementTree as ET from .target import Target +from .property_decorators import property_is_boolean class SubscriptionItem(object): def __init__(self, subject, schedule_id, user_id, target): - self.id = None - self.subject = subject + self._id = None + self.attach_image = True + self.attach_pdf = False + self.message = None + self.page_orientation = None + self.page_size_option = None self.schedule_id = schedule_id - self.user_id = user_id + self.send_if_view_empty = True + self.subject = subject + self.suspended = False self.target = target + self.user_id = user_id def __repr__(self): if self.id is not None: @@ -19,8 +27,45 @@ def __repr__(self): return " - - + + - - + + diff --git a/test/test_subscription.py b/test/test_subscription.py index 2e4b1eadf..15b845e56 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -28,14 +28,37 @@ def test_get_subscriptions(self): m.get(self.baseurl, text=response_xml) all_subscriptions, pagination_item = self.server.subscriptions.get() + self.assertEqual(2, pagination_item.total_available) subscription = all_subscriptions[0] self.assertEqual('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', subscription.id) - self.assertEqual('View', subscription.target.type) + self.assertEqual('NOT FOUND!', subscription.message) + self.assertTrue(subscription.attach_image) + self.assertFalse(subscription.attach_pdf) + self.assertFalse(subscription.suspended) + self.assertFalse(subscription.send_if_view_empty) + self.assertIsNone(subscription.page_orientation) + self.assertIsNone(subscription.page_size_option) + self.assertEqual('Not Found Alert', subscription.subject) self.assertEqual('cdd716ca-5818-470e-8bec-086885dbadee', subscription.target.id) + self.assertEqual('View', subscription.target.type) self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('Not Found Alert', subscription.subject) self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) + subscription = all_subscriptions[1] + self.assertEqual('23cb7630-afc8-4c8e-b6cd-83ae0322ec66', subscription.id) + self.assertEqual('overview', subscription.message) + self.assertFalse(subscription.attach_image) + self.assertTrue(subscription.attach_pdf) + self.assertTrue(subscription.suspended) + self.assertTrue(subscription.send_if_view_empty) + self.assertEqual('PORTRAIT', subscription.page_orientation) + self.assertEqual('A5', subscription.page_size_option) + self.assertEqual('Last 7 Days', subscription.subject) + self.assertEqual('2e6b4e8f-22dd-4061-8f75-bf33703da7e5', subscription.target.id) + self.assertEqual('Workbook', subscription.target.type) + self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) + self.assertEqual('3407cd38-7b39-4983-86a6-67a1506a5e3f', subscription.schedule_id) + def test_get_subscription_by_id(self): with open(GET_XML_BY_ID, "rb") as f: response_xml = f.read().decode("utf-8") From f64fcf9a1c6d775a953aaedd966ca3c16fcb79fd Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 17 Feb 2021 01:21:46 +0800 Subject: [PATCH 222/567] MANIFEST.in: Add docs and test data (#780) * Update publish_workbook.py (#694) * Update publish_workbook.py Added below arguments, without this there is a sign-in error on publishing a test file to Tableau Online parser.add_argument('--sitename', '-S', default='', help='sitename required') tableau_auth = TSC.TableauAuth(args.username, password,site_id=args.sitename) * Update publish_workbook.py Edits (as requested) to publish workbooks on Tableau Online which removes the Sign-in Error. * Update publish_workbook.py * Merge pull request #745 from tableau/fix_732 Server versions before 2020.1 do not accept encoded query param delimiters * Merge pull request #757 from tableau/fix_754 Fixes issue #754 by moving file read logic inside generator * Updates changelog for v0.14.1 * MANIFEST.in: Add docs and test data Closes https://github.com/tableau/server-client-python/issues/779 Co-authored-by: Chris Shin Co-authored-by: Madhura Selvarajan --- MANIFEST.in | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index ae0a2ec7d..b4b1425f3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,22 @@ include versioneer.py include tableauserverclient/_version.py include LICENSE include LICENSE.versioneer +include README.md +include CHANGELOG.md +recursive-include docs *.md +recursive-include samples *.py +recursive-include samples *.txt +recursive-include smoke *.py +recursive-include test *.csv +recursive-include test *.dict +recursive-include test *.hyper +recursive-include test *.json +recursive-include test *.pdf +recursive-include test *.png +recursive-include test *.py +recursive-include test *.tde +recursive-include test *.tds +recursive-include test *.tdsx +recursive-include test *.twb +recursive-include test *.twbx +recursive-include test *.xml From fe992ee909cf7a01749504388b84f3afadf43bf5 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 16 Feb 2021 14:33:59 -0800 Subject: [PATCH 223/567] [Tasks] Translate task type from server to TSC enum (#796) * Adds task type mapping to translate server response * Updates tests to match server response for task type * Fixes pycodestyle error --- tableauserverclient/models/task_item.py | 15 ++++++++++++++- test/assets/tasks_no_workbook_or_datasource.xml | 6 +++--- test/assets/tasks_with_dataacceleration_task.xml | 2 +- test/assets/tasks_with_datasource.xml | 2 +- test/assets/tasks_with_workbook.xml | 2 +- .../assets/tasks_with_workbook_and_datasource.xml | 6 +++--- test/test_task.py | 2 ++ 7 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 2f3e6f3aa..27d26f215 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -9,6 +9,10 @@ class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" + # This mapping is used to convert task type returned from server + _TASK_TYPE_MAPPING = {'RefreshExtractTask': Type.ExtractRefresh, + 'MaterializeViewsTask': Type.DataAcceleration} + def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, schedule_item=None, last_run_at=None, target=None): self.id = id_ @@ -58,9 +62,18 @@ def _parse_element(cls, element, ns): if last_run_at_element is not None: last_run_at = parse_datetime(last_run_at_element.text) - task_type = element.get('type', None) + # Server response has different names for task types + task_type = cls._translate_task_type(element.get('type', None)) + priority = int(element.get('priority', -1)) consecutive_failed_count = int(element.get('consecutiveFailedCount', 0)) id_ = element.get('id', None) return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id, schedule_item, last_run_at, target) + + @staticmethod + def _translate_task_type(task_type): + if task_type in TaskItem._TASK_TYPE_MAPPING: + return TaskItem._TASK_TYPE_MAPPING[task_type] + else: + return task_type diff --git a/test/assets/tasks_no_workbook_or_datasource.xml b/test/assets/tasks_no_workbook_or_datasource.xml index 7ddbcae62..da84194bf 100644 --- a/test/assets/tasks_no_workbook_or_datasource.xml +++ b/test/assets/tasks_no_workbook_or_datasource.xml @@ -4,17 +4,17 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd"> - + - + - + diff --git a/test/assets/tasks_with_dataacceleration_task.xml b/test/assets/tasks_with_dataacceleration_task.xml index cbe837405..beb5d59eb 100644 --- a/test/assets/tasks_with_dataacceleration_task.xml +++ b/test/assets/tasks_with_dataacceleration_task.xml @@ -2,7 +2,7 @@ - + diff --git a/test/assets/tasks_with_datasource.xml b/test/assets/tasks_with_datasource.xml index 68e23a417..097161bf7 100644 --- a/test/assets/tasks_with_datasource.xml +++ b/test/assets/tasks_with_datasource.xml @@ -4,7 +4,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd"> - + diff --git a/test/assets/tasks_with_workbook.xml b/test/assets/tasks_with_workbook.xml index 1565abf74..81e974e78 100644 --- a/test/assets/tasks_with_workbook.xml +++ b/test/assets/tasks_with_workbook.xml @@ -4,7 +4,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd"> - + diff --git a/test/assets/tasks_with_workbook_and_datasource.xml b/test/assets/tasks_with_workbook_and_datasource.xml index 4389fa06c..81777bb46 100644 --- a/test/assets/tasks_with_workbook_and_datasource.xml +++ b/test/assets/tasks_with_workbook_and_datasource.xml @@ -4,19 +4,19 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd"> - + - + - + diff --git a/test/test_task.py b/test/test_task.py index 789f97187..566167d4a 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -104,6 +104,7 @@ def test_get_materializeviews_tasks(self): self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + self.assertEqual(TSC.TaskItem.Type.DataAcceleration, task.task_type) def test_delete_data_acceleration(self): with requests_mock.mock() as m: @@ -124,6 +125,7 @@ def test_get_by_id(self): self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) self.assertEqual('workbook', task.target.type) self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + self.assertEqual(TSC.TaskItem.Type.ExtractRefresh, task.task_type) def test_run_now(self): task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' From 9179637b8cd3eb00ad7a12a44de9083e4ad39344 Mon Sep 17 00:00:00 2001 From: Lee Boynton Date: Tue, 16 Feb 2021 23:09:22 +0000 Subject: [PATCH 224/567] Add support for getting groups that a user belongs to (#799) * Add support for getting groups that a user belongs to * Use more descriptive name for pager function --- tableauserverclient/models/user_item.py | 11 ++++++++ .../server/endpoint/users_endpoint.py | 22 ++++++++++++++- test/assets/user_populate_groups.xml | 15 +++++++++++ test/test_user.py | 27 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 test/assets/user_populate_groups.xml diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 9be38210f..b5a05b0d1 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -43,6 +43,7 @@ def __init__(self, name=None, site_role=None, auth_setting=None): self._last_login = None self._workbooks = None self._favorites = None + self._groups = None self.email = None self.fullname = None self.name = name @@ -107,12 +108,22 @@ def favorites(self): raise UnpopulatedPropertyError(error) return self._favorites + @property + def groups(self): + if self._groups is None: + error = "User item must be populated with groups first." + raise UnpopulatedPropertyError(error) + return self._groups() + def to_reference(self): return ResourceReference(id_=self.id, tag_name=self.tag_name) def _set_workbooks(self, workbooks): self._workbooks = workbooks + def _set_groups(self, groups): + self._groups = groups + def _parse_common_tags(self, user_xml, ns): if not isinstance(user_xml, ET.Element): user_xml = ET.fromstring(user_xml).find('.//t:user', namespaces=ns) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5d3c69b26..17e12a8b1 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem +from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager import copy @@ -96,3 +96,23 @@ def _get_wbs_for_user(self, user_item, req_options=None): def populate_favorites(self, user_item): self.parent_srv.favorites.get(user_item) + + # Get groups for user + @api(version="3.7") + def populate_groups(self, user_item, req_options=None): + if not user_item.id: + error = "User item missing ID." + raise MissingRequiredFieldError(error) + + def groups_for_user_pager(): + return Pager(lambda options: self._get_groups_for_user(user_item, options), req_options) + + user_item._set_groups(groups_for_user_pager) + + def _get_groups_for_user(self, user_item, req_options=None): + url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + server_response = self.get_request(url, req_options) + logger.info('Populated groups for user (ID: {0})'.format(user_item.id)) + group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + return group_item, pagination_item diff --git a/test/assets/user_populate_groups.xml b/test/assets/user_populate_groups.xml new file mode 100644 index 000000000..567f1dbf8 --- /dev/null +++ b/test/assets/user_populate_groups.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/test_user.py b/test/test_user.py index db0f829f7..e4d1d6717 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -13,6 +13,7 @@ ADD_XML = os.path.join(TEST_ASSET_DIR, 'user_add.xml') POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_workbooks.xml') GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, 'favorites_get.xml') +POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_groups.xml') class UserTests(unittest.TestCase): @@ -175,3 +176,29 @@ def test_populate_favorites(self): self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') + + def test_populate_groups(self): + self.server.version = '3.7' + with open(POPULATE_GROUPS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.server.users.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794/groups', + text=response_xml) + single_user = TSC.UserItem('test', 'Interactor') + single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + self.server.users.populate_groups(single_user) + + group_list = list(single_user.groups) + + self.assertEqual(3, len(group_list)) + self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group_list[0].id) + self.assertEqual('All Users', group_list[0].name) + self.assertEqual('local', group_list[0].domain_name) + + self.assertEqual('e7833b48-c6f7-47b5-a2a7-36e7dd232758', group_list[1].id) + self.assertEqual('Another group', group_list[1].name) + self.assertEqual('local', group_list[1].domain_name) + + self.assertEqual('86a66d40-f289-472a-83d0-927b0f954dc8', group_list[2].id) + self.assertEqual('TableauExample', group_list[2].name) + self.assertEqual('local', group_list[2].domain_name) From 6c7a87b3e4e119621490f049d38d234a1c840a5b Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 16 Feb 2021 15:09:45 -0800 Subject: [PATCH 225/567] Removes travis and adds linting/testing into github action (#798) * Removes travis and adds github workflow * Addressing code review feedback --- .github/workflows/run-tests.yml | 33 +++++++++++++++++++++++++++++++++ .travis.yml | 17 ----------------- setup.py | 10 +++++----- 3 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/run-tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 000000000..e12d61383 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,33 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Lint with pycodestyle + run: | + pycodestyle tableauserverclient test samples + + - name: Test with pytest + run: | + pytest test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9085632f4..000000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -dist: xenial -language: python -python: - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "3.9" -# command to install dependencies -install: - - "pip install -e ." - - "pip install pycodestyle" -# command to run tests -script: - # Tests - - python setup.py test - - pycodestyle tableauserverclient test samples diff --git a/setup.py b/setup.py index 5586e4716..8b374f0ce 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] +test_requirements = ['mock', 'pycodestyle', 'pytest', 'requests-mock>=1.0,<2.0'] setup( name='tableauserverclient', @@ -34,9 +35,8 @@ install_requires=[ 'requests>=2.11,<3.0', ], - tests_require=[ - 'requests-mock>=1.0,<2.0', - 'pytest', - 'mock' - ] + tests_require=test_requirements, + extras_require={ + 'test': test_requirements + } ) From 004ab31140b3a4ee6157c36d8c6c434597151dd8 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Tue, 16 Feb 2021 16:49:19 -0800 Subject: [PATCH 226/567] Updates changelog and contributors list for v0.15 --- CHANGELOG.md | 17 +++++++++++++++++ CONTRIBUTORS.md | 3 +++ 2 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85dc8a702..45a44b251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.15.0 (16 Feb 2021) +* Added support for python version 3.9 (#744) +* Added support for 'Get View by ID' (#750) +* Added docs and test data to MANIFEST.in file (#780) +* Added owner_id property to ProjectItem (#784) +* Added support for skipping connection check while publishing workbook (#791) +* Added support for 'Update Subscription' (#794) +* Added support for 'Get Groups for a User' (#799) +* Improved debug logging by including put/post request contents (#743) +* Improved local and active-directory group creation (#770) +* Improved 'Update Group' to match server requests/responses (#772) +* Improved SiteItem with new properties and functions (#777) +* Improved SubscriptionItem with new properties (#794) +* Improved the 'type' property of TaskItem to convert server response to enum (#796) +* Improved repository to use Github Actions for running tests/linter (#798) +* Fixed data_acceleration field causing error in workbook update payload (#741) + ## 0.14.1 (9 Dec 2020) * Fixed filter query issue for server version below 2020.1 (#745) * Fixed large workbook/datasource publish issue (#757) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 811f5c5bf..2a19b1317 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -41,6 +41,9 @@ The following people have contributed to this project to make it possible, and w * [Paul Vickers](https://github.com/paulvic) * [Madhura Selvarajan](https://github.com/maddy-at-leisure) * [Niklas Nevalainen](https://github.com/nnevalainen) +* [Terrence Jones](https://github.com/tjones-commits) +* [John Vandenberg](https://github.com/jayvdb) +* [Lee Boynton](https://github.com/lboynton) ## Core Team From d107c4827cd9ea59cdb9f618952eea35aef98197 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 17 Feb 2021 15:38:14 -0800 Subject: [PATCH 227/567] Add Mypy to CI runs (#802) Add mypy runs to CI, but skip misc errors, so we only see the important stuff. For now these are non blocking. --- .github/workflows/run-tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e12d61383..a0917c7b6 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -23,6 +23,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] + pip install mypy - name: Lint with pycodestyle run: | @@ -31,3 +32,7 @@ jobs: - name: Test with pytest run: | pytest test + - name: Run Mypy but allow failures + run: | + mypy --show-error-codes --disable-error-code misc tableauserverclient + continue-on-error: true From 42f710ebe4cb2b90d80724a66de35a980b10b1a2 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 5 Mar 2021 13:37:46 -0800 Subject: [PATCH 228/567] Change build badge from Travis to GitHub Actions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e2c30704a..366a4565b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tableau Server Client (Python) -[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://travis-ci.org/tableau/server-client-python.svg?branch=master)](https://travis-ci.org/tableau/server-client-python) +![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) ![Build Status](https://github.com/tableau/server-client-python/actions/workflows/run-tests.yml/badge.svg) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: From acbaa8ccb3b6ba883723dd52842f2a4c3fbc78b5 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 5 Mar 2021 13:41:18 -0800 Subject: [PATCH 229/567] Fix badge links --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 366a4565b..1aed88d61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tableau Server Client (Python) -![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) ![Build Status](https://github.com/tableau/server-client-python/actions/workflows/run-tests.yml/badge.svg) +[![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://github.com/tableau/server-client-python/actions/workflows/run-tests.yml/badge.svg)](https://github.com/tableau/server-client-python/actions) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: From aa5e675ea2320b4e76d5b3d6b52b9fad8888728f Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 5 Mar 2021 14:06:35 -0800 Subject: [PATCH 230/567] Add issue template --- .github/ISSUE_TEMPLATE/bug_report.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..a199226df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Create a bug report or request for help +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Versions** +Details of your environment, including: + - Tableau Server version (or note if using Tableau Online) + - Python version + - TSC library version + +**To Reproduce** +Steps to reproduce the behavior. Please include a code snippet where possible. + +**Results** +What are the results or error messages received? + +**NOTE:** Be careful not to post user names, passwords, auth tokens or any other private or sensitive information. From 39560e55cab644fe8867db9cc0fc2bc03f884322 Mon Sep 17 00:00:00 2001 From: t8y8 Date: Wed, 21 Apr 2021 13:29:30 -0700 Subject: [PATCH 231/567] reformat the world, once and for all --- tableauserverclient/__init__.py | 59 +- tableauserverclient/_version.py | 132 +-- tableauserverclient/filesys_helpers.py | 11 +- tableauserverclient/models/column_item.py | 10 +- .../models/connection_credentials.py | 10 +- tableauserverclient/models/connection_item.py | 43 +- .../models/data_acceleration_report_item.py | 55 +- tableauserverclient/models/data_alert_item.py | 89 +- tableauserverclient/models/database_item.py | 90 +- tableauserverclient/models/datasource_item.py | 202 +++-- tableauserverclient/models/favorites_item.py | 40 +- tableauserverclient/models/fileupload_item.py | 6 +- tableauserverclient/models/flow_item.py | 60 +- tableauserverclient/models/group_item.py | 24 +- tableauserverclient/models/interval_item.py | 4 +- tableauserverclient/models/job_item.py | 68 +- tableauserverclient/models/pagination_item.py | 8 +- .../models/permissions_item.py | 79 +- .../models/personal_access_token_auth.py | 11 +- tableauserverclient/models/project_item.py | 24 +- .../models/property_decorators.py | 29 +- tableauserverclient/models/reference_item.py | 1 - tableauserverclient/models/schedule_item.py | 159 ++-- .../models/server_info_item.py | 6 +- tableauserverclient/models/site_item.py | 603 ++++++++++---- .../models/subscription_item.py | 44 +- tableauserverclient/models/table_item.py | 40 +- tableauserverclient/models/tableau_auth.py | 20 +- tableauserverclient/models/tag_item.py | 4 +- tableauserverclient/models/target.py | 2 +- tableauserverclient/models/task_item.py | 48 +- tableauserverclient/models/user_item.py | 89 +- tableauserverclient/models/view_item.py | 36 +- tableauserverclient/models/webhook_item.py | 23 +- tableauserverclient/models/workbook_item.py | 198 +++-- tableauserverclient/namespace.py | 12 +- tableauserverclient/server/__init__.py | 51 +- .../server/endpoint/auth_endpoint.py | 37 +- .../data_acceleration_report_endpoint.py | 8 +- .../server/endpoint/data_alert_endpoint.py | 22 +- .../server/endpoint/databases_endpoint.py | 38 +- .../server/endpoint/datasources_endpoint.py | 118 +-- .../endpoint/default_permissions_endpoint.py | 38 +- .../server/endpoint/endpoint.py | 71 +- .../server/endpoint/exceptions.py | 9 +- .../server/endpoint/favorites_endpoint.py | 38 +- .../server/endpoint/fileuploads_endpoint.py | 12 +- .../server/endpoint/flows_endpoint.py | 75 +- .../server/endpoint/groups_endpoint.py | 29 +- .../server/endpoint/jobs_endpoint.py | 17 +- .../server/endpoint/metadata_endpoint.py | 64 +- .../server/endpoint/permissions_endpoint.py | 34 +- .../server/endpoint/projects_endpoint.py | 45 +- .../server/endpoint/resource_tagger.py | 4 +- .../server/endpoint/schedules_endpoint.py | 10 +- .../server/endpoint/server_info_endpoint.py | 2 +- .../server/endpoint/sites_endpoint.py | 22 +- .../server/endpoint/subscriptions_endpoint.py | 21 +- .../server/endpoint/tables_endpoint.py | 34 +- .../server/endpoint/tasks_endpoint.py | 39 +- .../server/endpoint/users_endpoint.py | 16 +- .../server/endpoint/views_endpoint.py | 18 +- .../server/endpoint/webhooks_endpoint.py | 12 +- .../server/endpoint/workbooks_endpoint.py | 144 ++-- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 4 +- tableauserverclient/server/query.py | 3 +- tableauserverclient/server/request_factory.py | 786 +++++++++--------- tableauserverclient/server/request_options.py | 100 +-- tableauserverclient/server/server.py | 54 +- tableauserverclient/server/sort.py | 2 +- 71 files changed, 2530 insertions(+), 1790 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index b438d8a2e..2eadcdfa1 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,13 +1,54 @@ from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ConnectionCredentials, ConnectionItem, DataAlertItem, DatasourceItem,\ - 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, \ - WebhookItem, PersonalAccessTokenAuth -from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ - Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager +from .models import ( + ConnectionCredentials, + ConnectionItem, + DataAlertItem, + DatasourceItem, + 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, + WebhookItem, + PersonalAccessTokenAuth, +) +from .server import ( + RequestOptions, + CSVRequestOptions, + ImageRequestOptions, + PDFRequestOptions, + Filter, + Sort, + Server, + ServerResponseError, + MissingRequiredFieldError, + NotSignedInError, + Pager, +) from ._version import get_versions -__version__ = get_versions()['version'] + +__version__ = get_versions()["version"] __VERSION__ = __version__ del get_versions diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 9f576606a..c8afb10d4 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -1,4 +1,3 @@ - # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -58,17 +57,18 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" + def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f + return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None @@ -76,10 +76,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + p = subprocess.Popen( + [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + ) break except EnvironmentError: e = sys.exc_info()[1] @@ -116,16 +115,19 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} + return { + "version": dirname[len(parentdir_prefix) :], + "full-revisionid": None, + "dirty": False, + "error": None, + "date": None, + } else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -181,7 +183,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -190,7 +192,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -198,19 +200,26 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} + return { + "version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": None, + "date": date, + } # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} + return { + "version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": "no suitable tags", + "date": None, + } @register_vcs_handler("git", "pieces_from_vcs") @@ -225,8 +234,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -234,10 +242,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = run_command( + GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root + ) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -260,17 +267,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] + git_describe = git_describe[: git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) + pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out return pieces # tag @@ -279,10 +285,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -293,13 +298,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) + count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], - cwd=root)[0].strip() + date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -330,8 +333,7 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered @@ -445,11 +447,13 @@ def render_git_describe_long(pieces): def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} + return { + "version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None, + } if not style or style == "default": style = "pep440" # the default @@ -469,9 +473,13 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} + return { + "version": rendered, + "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], + "error": None, + "date": pieces.get("date"), + } def get_versions(): @@ -485,8 +493,7 @@ def get_versions(): verbose = cfg.verbose try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) except NotThisMethod: pass @@ -495,13 +502,16 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): + for i in cfg.versionfile_source.split("/"): root = os.path.dirname(root) except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None, + } try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -515,6 +525,10 @@ def get_versions(): except NotThisMethod: pass - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", + "date": None, + } diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py index 11051fdf4..1663ee9e8 100644 --- a/tableauserverclient/filesys_helpers.py +++ b/tableauserverclient/filesys_helpers.py @@ -1,5 +1,6 @@ import os -ALLOWED_SPECIAL = (' ', '.', '_', '-') + +ALLOWED_SPECIAL = (" ", ".", "_", "-") def to_filename(string_to_sanitize): @@ -37,10 +38,10 @@ def get_file_type(file): # This reference lists magic file signatures: https://www.garykessler.net/library/file_sigs.html MAGIC_BYTES = { - 'zip': bytes.fromhex("504b0304"), - 'tde': bytes.fromhex("20020162"), - 'xml': bytes.fromhex("3c3f786d6c20"), - 'hyper': bytes.fromhex("487970657208000001000000") + "zip": bytes.fromhex("504b0304"), + "tde": bytes.fromhex("20020162"), + "xml": bytes.fromhex("3c3f786d6c20"), + "hyper": bytes.fromhex("487970657208000001000000"), } # Peek first bytes of a file diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 9bf198220..a95d005ca 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -48,7 +48,7 @@ def _set_values(self, id, name, description, remote_type): def from_response(cls, resp, ns): all_column_items = list() parsed_response = ET.fromstring(resp) - all_column_xml = parsed_response.findall('.//t:column', namespaces=ns) + all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) for column_xml in all_column_xml: (id, name, description, remote_type) = cls._parse_element(column_xml, ns) @@ -60,9 +60,9 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(column_xml, ns): - id = column_xml.get('id', None) - name = column_xml.get('name', None) - description = column_xml.get('description', None) - remote_type = column_xml.get('remoteType', None) + id = column_xml.get("id", None) + name = column_xml.get("name", None) + description = column_xml.get("description", None) + remote_type = column_xml.get("remoteType", None) return id, name, description, remote_type diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index c883a515a..db65de0ad 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -35,12 +35,12 @@ def oauth(self, value): @classmethod def from_xml_element(cls, parsed_response, ns): - connection_creds_xml = parsed_response.find('.//t:connectionCredentials', namespaces=ns) + connection_creds_xml = parsed_response.find(".//t:connectionCredentials", namespaces=ns) - name = connection_creds_xml.get('name', None) - password = connection_creds_xml.get('password', None) - embed = connection_creds_xml.get('embed', None) - oAuth = connection_creds_xml.get('oAuth', None) + name = connection_creds_xml.get("name", None) + password = connection_creds_xml.get("password", None) + embed = connection_creds_xml.get("embed", None) + oAuth = connection_creds_xml.get("oAuth", None) connection_creds = cls(name, password, embed, oAuth) return connection_creds diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 8f923fecb..018c093c7 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -32,34 +32,33 @@ def connection_type(self): return self._connection_type def __repr__(self): - return ( - "".format(**self.__dict__) + return "".format( + **self.__dict__ ) @classmethod def from_response(cls, resp, ns): all_connection_items = list() parsed_response = ET.fromstring(resp) - all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns) + all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() - connection_item._id = connection_xml.get('id', None) - connection_item._connection_type = connection_xml.get('type', None) - connection_item.embed_password = string_to_bool(connection_xml.get('embedPassword', '')) - connection_item.server_address = connection_xml.get('serverAddress', None) - connection_item.server_port = connection_xml.get('serverPort', None) - connection_item.username = connection_xml.get('userName', None) - datasource_elem = connection_xml.find('.//t:datasource', namespaces=ns) + connection_item._id = connection_xml.get("id", None) + connection_item._connection_type = connection_xml.get("type", None) + connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) + connection_item.server_address = connection_xml.get("serverAddress", None) + connection_item.server_port = connection_xml.get("serverPort", None) + connection_item.username = connection_xml.get("userName", None) + datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: - connection_item._datasource_id = datasource_elem.get('id', None) - connection_item._datasource_name = datasource_elem.get('name', None) + connection_item._datasource_id = datasource_elem.get("id", None) + connection_item._datasource_name = datasource_elem.get("name", None) all_connection_items.append(connection_item) return all_connection_items @classmethod def from_xml_element(cls, parsed_response, ns): - ''' + """ @@ -68,27 +67,25 @@ def from_xml_element(cls, parsed_response, ns): - ''' + """ all_connection_items = list() - all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns) + all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() - connection_item.server_address = connection_xml.get('serverAddress', None) - connection_item.server_port = connection_xml.get('serverPort', None) + connection_item.server_address = connection_xml.get("serverAddress", None) + connection_item.server_port = connection_xml.get("serverPort", None) - connection_credentials = connection_xml.find( - './/t:connectionCredentials', namespaces=ns) + connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element( - connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) return all_connection_items # Used to convert string represented boolean to a boolean type def string_to_bool(s): - return s.lower() == 'true' + return s.lower() == "true" diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 2b443a3d1..eab6c24cd 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -3,9 +3,15 @@ class DataAccelerationReportItem(object): class ComparisonRecord(object): - def __init__(self, site, sheet_uri, unaccelerated_session_count, - avg_non_accelerated_plt, accelerated_session_count, - avg_accelerated_plt): + def __init__( + self, + site, + sheet_uri, + unaccelerated_session_count, + avg_non_accelerated_plt, + accelerated_session_count, + avg_accelerated_plt, + ): self._site = site self._sheet_uri = sheet_uri self._unaccelerated_session_count = unaccelerated_session_count @@ -46,26 +52,43 @@ def comparison_records(self): @staticmethod def _parse_element(comparison_record_xml, ns): - site = comparison_record_xml.get('site', None) - sheet_uri = comparison_record_xml.get('sheetURI', None) - unaccelerated_session_count = comparison_record_xml.get('unacceleratedSessionCount', None) - avg_non_accelerated_plt = comparison_record_xml.get('averageNonAcceleratedPLT', None) - accelerated_session_count = comparison_record_xml.get('acceleratedSessionCount', None) - avg_accelerated_plt = comparison_record_xml.get('averageAcceleratedPLT', None) - return site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, \ - accelerated_session_count, avg_accelerated_plt + site = comparison_record_xml.get("site", None) + sheet_uri = comparison_record_xml.get("sheetURI", None) + unaccelerated_session_count = comparison_record_xml.get("unacceleratedSessionCount", None) + avg_non_accelerated_plt = comparison_record_xml.get("averageNonAcceleratedPLT", None) + accelerated_session_count = comparison_record_xml.get("acceleratedSessionCount", None) + avg_accelerated_plt = comparison_record_xml.get("averageAcceleratedPLT", None) + return ( + site, + sheet_uri, + unaccelerated_session_count, + avg_non_accelerated_plt, + accelerated_session_count, + avg_accelerated_plt, + ) @classmethod def from_response(cls, resp, ns): comparison_records = list() parsed_response = ET.fromstring(resp) - all_comparison_records_xml = parsed_response.findall('.//t:comparisonRecord', namespaces=ns) + all_comparison_records_xml = parsed_response.findall(".//t:comparisonRecord", namespaces=ns) for comparison_record_xml in all_comparison_records_xml: - (site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, - accelerated_session_count, avg_accelerated_plt) = cls._parse_element(comparison_record_xml, ns) + ( + site, + sheet_uri, + unaccelerated_session_count, + avg_non_accelerated_plt, + accelerated_session_count, + avg_accelerated_plt, + ) = cls._parse_element(comparison_record_xml, ns) comparison_record = DataAccelerationReportItem.ComparisonRecord( - site, sheet_uri, unaccelerated_session_count, avg_non_accelerated_plt, - accelerated_session_count, avg_accelerated_plt) + site, + sheet_uri, + unaccelerated_session_count, + avg_non_accelerated_plt, + accelerated_session_count, + avg_accelerated_plt, + ) comparison_records.append(comparison_record) return cls(comparison_records) diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 559050b4b..c924b6ab2 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -7,11 +7,11 @@ class DataAlertItem(object): class Frequency: - Once = 'Once' - Frequently = 'Frequently' - Hourly = 'Hourly' - Daily = 'Daily' - Weekly = 'Weekly' + Once = "Once" + Frequently = "Frequently" + Hourly = "Hourly" + Daily = "Daily" + Weekly = "Weekly" def __init__(self): self._id = None @@ -33,7 +33,9 @@ def __init__(self): def __repr__(self): return "".format(**self.__dict__) + public={public}>".format( + **self.__dict__ + ) @property def id(self): @@ -114,10 +116,25 @@ def project_id(self): def project_name(self): return self._project_name - def _set_values(self, id, subject, creatorId, createdAt, updatedAt, - frequency, public, recipients, owner_id, owner_name, - view_id, view_name, workbook_id, workbook_name, project_id, - project_name): + def _set_values( + self, + id, + subject, + creatorId, + createdAt, + updatedAt, + frequency, + public, + recipients, + owner_id, + owner_name, + view_id, + view_name, + workbook_id, + workbook_name, + project_id, + project_name, + ): if id is not None: self._id = id if subject: @@ -155,7 +172,7 @@ def _set_values(self, id, subject, creatorId, createdAt, updatedAt, def from_response(cls, resp, ns): all_alert_items = list() parsed_response = ET.fromstring(resp) - all_alert_xml = parsed_response.findall('.//t:dataAlert', namespaces=ns) + all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) for alert_xml in all_alert_xml: kwargs = cls._parse_element(alert_xml, ns) @@ -168,30 +185,30 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(alert_xml, ns): kwargs = dict() - kwargs['id'] = alert_xml.get('id', None) - kwargs['subject'] = alert_xml.get('subject', None) - kwargs['creatorId'] = alert_xml.get('creatorId', None) - kwargs['createdAt'] = alert_xml.get('createdAt', None) - kwargs['updatedAt'] = alert_xml.get('updatedAt', None) - kwargs['frequency'] = alert_xml.get('frequency', None) - kwargs['public'] = alert_xml.get('public', None) - - owner = alert_xml.findall('.//t:owner', namespaces=ns)[0] - kwargs['owner_id'] = owner.get('id', None) - kwargs['owner_name'] = owner.get('name', None) - - view_response = alert_xml.findall('.//t:view', namespaces=ns)[0] - kwargs['view_id'] = view_response.get('id', None) - kwargs['view_name'] = view_response.get('name', None) - - workbook_response = view_response.findall('.//t:workbook', namespaces=ns)[0] - kwargs['workbook_id'] = workbook_response.get('id', None) - kwargs['workbook_name'] = workbook_response.get('name', None) - project_response = view_response.findall('.//t:project', namespaces=ns)[0] - kwargs['project_id'] = project_response.get('id', None) - kwargs['project_name'] = project_response.get('name', None) - - recipients = alert_xml.findall('.//t:recipient', namespaces=ns) - kwargs['recipients'] = [recipient.get('id', None) for recipient in recipients] + kwargs["id"] = alert_xml.get("id", None) + kwargs["subject"] = alert_xml.get("subject", None) + kwargs["creatorId"] = alert_xml.get("creatorId", None) + kwargs["createdAt"] = alert_xml.get("createdAt", None) + kwargs["updatedAt"] = alert_xml.get("updatedAt", None) + kwargs["frequency"] = alert_xml.get("frequency", None) + kwargs["public"] = alert_xml.get("public", None) + + owner = alert_xml.findall(".//t:owner", namespaces=ns)[0] + kwargs["owner_id"] = owner.get("id", None) + kwargs["owner_name"] = owner.get("name", None) + + view_response = alert_xml.findall(".//t:view", namespaces=ns)[0] + kwargs["view_id"] = view_response.get("id", None) + kwargs["view_name"] = view_response.get("name", None) + + workbook_response = view_response.findall(".//t:workbook", namespaces=ns)[0] + kwargs["workbook_id"] = workbook_response.get("id", None) + kwargs["workbook_name"] = workbook_response.get("name", None) + project_response = view_response.findall(".//t:project", namespaces=ns)[0] + kwargs["project_id"] = project_response.get("id", None) + kwargs["project_name"] = project_response.get("name", None) + + recipients = alert_xml.findall(".//t:recipient", namespaces=ns) + kwargs["recipients"] = [recipient.get("id", None) for recipient in recipients] return kwargs diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 5a7e74737..a319606e4 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -6,8 +6,8 @@ class DatabaseItem(object): class ContentPermissions: - LockedToProject = 'LockedToDatabase' - ManagedByOwner = 'ManagedByOwner' + LockedToProject = "LockedToDatabase" + ManagedByOwner = "ManagedByOwner" def __init__(self, name, description=None, content_permissions=None): self._id = None @@ -163,64 +163,64 @@ def tables(self): def _set_values(self, database_values): # ID & Settable - if 'id' in database_values: - self._id = database_values['id'] + if "id" in database_values: + self._id = database_values["id"] - if 'contact' in database_values: - self._contact_id = database_values['contact']['id'] + if "contact" in database_values: + self._contact_id = database_values["contact"]["id"] - if 'name' in database_values: - self._name = database_values['name'] + if "name" in database_values: + self._name = database_values["name"] - if 'description' in database_values: - self._description = database_values['description'] + if "description" in database_values: + self._description = database_values["description"] - if 'isCertified' in database_values: - self._certified = string_to_bool(database_values['isCertified']) + if "isCertified" in database_values: + self._certified = string_to_bool(database_values["isCertified"]) - if 'certificationNote' in database_values: - self._certification_note = database_values['certificationNote'] + if "certificationNote" in database_values: + self._certification_note = database_values["certificationNote"] # Not settable, alphabetical - if 'connectionType' in database_values: - self._connection_type = database_values['connectionType'] + if "connectionType" in database_values: + self._connection_type = database_values["connectionType"] - if 'connectorUrl' in database_values: - self._connector_url = database_values['connectorUrl'] + if "connectorUrl" in database_values: + self._connector_url = database_values["connectorUrl"] - if 'contentPermissions' in database_values: - self._content_permissions = database_values['contentPermissions'] + if "contentPermissions" in database_values: + self._content_permissions = database_values["contentPermissions"] - if 'isEmbedded' in database_values: - self._embedded = string_to_bool(database_values['isEmbedded']) + if "isEmbedded" in database_values: + self._embedded = string_to_bool(database_values["isEmbedded"]) - if 'fileExtension' in database_values: - self._file_extension = database_values['fileExtension'] + if "fileExtension" in database_values: + self._file_extension = database_values["fileExtension"] - if 'fileId' in database_values: - self._file_id = database_values['fileId'] + if "fileId" in database_values: + self._file_id = database_values["fileId"] - if 'filePath' in database_values: - self._file_path = database_values['filePath'] + if "filePath" in database_values: + self._file_path = database_values["filePath"] - if 'hostName' in database_values: - self._host_name = database_values['hostName'] + if "hostName" in database_values: + self._host_name = database_values["hostName"] - if 'mimeType' in database_values: - self._mime_type = database_values['mimeType'] + if "mimeType" in database_values: + self._mime_type = database_values["mimeType"] - if 'port' in database_values: - self._port = int(database_values['port']) + if "port" in database_values: + self._port = int(database_values["port"]) - if 'provider' in database_values: - self._provider = database_values['provider'] + if "provider" in database_values: + self._provider = database_values["provider"] - if 'requestUrl' in database_values: - self._request_url = database_values['requestUrl'] + if "requestUrl" in database_values: + self._request_url = database_values["requestUrl"] - if 'type' in database_values: - self._metadata_type = database_values['type'] + if "type" in database_values: + self._metadata_type = database_values["type"] def _set_permissions(self, permissions): self._permissions = permissions @@ -235,11 +235,11 @@ def _set_default_permissions(self, permissions, content_type): def from_response(cls, resp, ns): all_database_items = list() parsed_response = ET.fromstring(resp) - all_database_xml = parsed_response.findall('.//t:database', namespaces=ns) + all_database_xml = parsed_response.findall(".//t:database", namespaces=ns) for database_xml in all_database_xml: parsed_database = cls._parse_element(database_xml, ns) - database_item = cls(parsed_database['name']) + database_item = cls(parsed_database["name"]) database_item._set_values(parsed_database) all_database_items.append(database_item) return all_database_items @@ -247,12 +247,12 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(database_xml, ns): database_values = database_xml.attrib.copy() - contact = database_xml.find('.//t:contact', namespaces=ns) + contact = database_xml.find(".//t:contact", namespaces=ns) if contact is not None: - database_values['contact'] = contact.attrib.copy() + database_values["contact"] = contact.attrib.copy() return database_values # Used to convert string represented boolean to a boolean type def string_to_bool(s): - return s.lower() == 'true' + return s.lower() == "true" diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index a50d5a412..219df39c2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -8,9 +8,9 @@ class DatasourceItem(object): class AskDataEnablement: - Enabled = 'Enabled' - Disabled = 'Disabled' - SiteDefault = 'SiteDefault' + Enabled = "Enabled" + Disabled = "Disabled" + SiteDefault = "SiteDefault" def __init__(self, project_id, name=None): self._ask_data_enablement = None @@ -48,7 +48,7 @@ def ask_data_enablement(self, value): @property def connections(self): if self._connections is None: - error = 'Datasource item must be populated with connections first.' + error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @@ -144,19 +144,71 @@ def _set_permissions(self, permissions): def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): - datasource_xml = ET.fromstring(datasource_xml).find('.//t:datasource', namespaces=ns) + datasource_xml = ET.fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) if datasource_xml is not None: - (ask_data_enablement, certified, certification_note, _, _, _, _, encrypt_extracts, has_extracts, - _, _, owner_id, project_id, project_name, _, updated_at, use_remote_query_agent, - webpage_url) = self._parse_element(datasource_xml, ns) - self._set_values(ask_data_enablement, certified, certification_note, None, None, None, None, - encrypt_extracts, has_extracts, None, None, owner_id, project_id, project_name, None, - updated_at, use_remote_query_agent, webpage_url) + ( + ask_data_enablement, + certified, + certification_note, + _, + _, + _, + _, + encrypt_extracts, + has_extracts, + _, + _, + owner_id, + project_id, + project_name, + _, + updated_at, + use_remote_query_agent, + webpage_url, + ) = self._parse_element(datasource_xml, ns) + self._set_values( + ask_data_enablement, + certified, + certification_note, + None, + None, + None, + None, + encrypt_extracts, + has_extracts, + None, + None, + owner_id, + project_id, + project_name, + None, + updated_at, + use_remote_query_agent, + webpage_url, + ) return self - def _set_values(self, ask_data_enablement, certified, certification_note, content_url, created_at, datasource_type, - description, encrypt_extracts, has_extracts, id_, name, owner_id, project_id, project_name, tags, - updated_at, use_remote_query_agent, webpage_url): + def _set_values( + self, + ask_data_enablement, + certified, + certification_note, + content_url, + created_at, + datasource_type, + description, + encrypt_extracts, + has_extracts, + id_, + name, + owner_id, + project_id, + project_name, + tags, + updated_at, + use_remote_query_agent, + webpage_url, + ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement if certification_note: @@ -171,9 +223,9 @@ def _set_values(self, ask_data_enablement, certified, certification_note, conten if description: self.description = description if encrypt_extracts is not None: - self.encrypt_extracts = str(encrypt_extracts).lower() == 'true' + self.encrypt_extracts = str(encrypt_extracts).lower() == "true" if has_extracts is not None: - self._has_extracts = str(has_extracts).lower() == 'true' + self._has_extracts = str(has_extracts).lower() == "true" if id_ is not None: self._id = id_ if name: @@ -190,7 +242,7 @@ def _set_values(self, ask_data_enablement, certified, certification_note, conten if updated_at: self._updated_at = updated_at if use_remote_query_agent is not None: - self._use_remote_query_agent = str(use_remote_query_agent).lower() == 'true' + self._use_remote_query_agent = str(use_remote_query_agent).lower() == "true" if webpage_url: self._webpage_url = webpage_url @@ -198,58 +250,108 @@ def _set_values(self, ask_data_enablement, certified, certification_note, conten def from_response(cls, resp, ns): all_datasource_items = list() parsed_response = ET.fromstring(resp) - all_datasource_xml = parsed_response.findall('.//t:datasource', namespaces=ns) + all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) for datasource_xml in all_datasource_xml: - (ask_data_enablement, certified, certification_note, content_url, created_at, datasource_type, - description, encrypt_extracts, has_extracts, id_, name, owner_id, project_id, project_name, tags, - updated_at, use_remote_query_agent, webpage_url) = cls._parse_element(datasource_xml, ns) + ( + ask_data_enablement, + certified, + certification_note, + content_url, + created_at, + datasource_type, + description, + encrypt_extracts, + has_extracts, + id_, + name, + owner_id, + project_id, + project_name, + tags, + updated_at, + use_remote_query_agent, + webpage_url, + ) = cls._parse_element(datasource_xml, ns) datasource_item = cls(project_id) - datasource_item._set_values(ask_data_enablement, certified, certification_note, content_url, - created_at, datasource_type, description, encrypt_extracts, - has_extracts, id_, name, owner_id, None, project_name, tags, updated_at, - use_remote_query_agent, webpage_url) + datasource_item._set_values( + ask_data_enablement, + certified, + certification_note, + content_url, + created_at, + datasource_type, + description, + encrypt_extracts, + has_extracts, + id_, + name, + owner_id, + None, + project_name, + tags, + updated_at, + use_remote_query_agent, + webpage_url, + ) all_datasource_items.append(datasource_item) return all_datasource_items @staticmethod def _parse_element(datasource_xml, ns): - certification_note = datasource_xml.get('certificationNote', None) - certified = str(datasource_xml.get('isCertified', None)).lower() == 'true' - content_url = datasource_xml.get('contentUrl', None) - created_at = parse_datetime(datasource_xml.get('createdAt', None)) - datasource_type = datasource_xml.get('type', None) - description = datasource_xml.get('description', None) - encrypt_extracts = datasource_xml.get('encryptExtracts', None) - has_extracts = datasource_xml.get('hasExtracts', None) - id_ = datasource_xml.get('id', None) - name = datasource_xml.get('name', None) - updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) - use_remote_query_agent = datasource_xml.get('useRemoteQueryAgent', None) - webpage_url = datasource_xml.get('webpageUrl', None) + certification_note = datasource_xml.get("certificationNote", None) + certified = str(datasource_xml.get("isCertified", None)).lower() == "true" + content_url = datasource_xml.get("contentUrl", None) + created_at = parse_datetime(datasource_xml.get("createdAt", None)) + datasource_type = datasource_xml.get("type", None) + description = datasource_xml.get("description", None) + encrypt_extracts = datasource_xml.get("encryptExtracts", None) + has_extracts = datasource_xml.get("hasExtracts", None) + id_ = datasource_xml.get("id", None) + name = datasource_xml.get("name", None) + updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) + use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) + webpage_url = datasource_xml.get("webpageUrl", None) tags = None - tags_elem = datasource_xml.find('.//t:tags', namespaces=ns) + tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) project_id = None project_name = None - project_elem = datasource_xml.find('.//t:project', namespaces=ns) + project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: - project_id = project_elem.get('id', None) - project_name = project_elem.get('name', None) + project_id = project_elem.get("id", None) + project_name = project_elem.get("name", None) owner_id = None - owner_elem = datasource_xml.find('.//t:owner', namespaces=ns) + owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: - owner_id = owner_elem.get('id', None) + owner_id = owner_elem.get("id", None) ask_data_enablement = None - ask_data_elem = datasource_xml.find('.//t:askData', namespaces=ns) + ask_data_elem = datasource_xml.find(".//t:askData", namespaces=ns) if ask_data_elem is not None: - ask_data_enablement = ask_data_elem.get('enablement', None) - - return (ask_data_enablement, certified, certification_note, content_url, created_at, - datasource_type, description, encrypt_extracts, has_extracts, id_, name, owner_id, - project_id, project_name, tags, updated_at, use_remote_query_agent, webpage_url) + ask_data_enablement = ask_data_elem.get("enablement", None) + + return ( + ask_data_enablement, + certified, + certification_note, + content_url, + created_at, + datasource_type, + description, + encrypt_extracts, + has_extracts, + id_, + name, + owner_id, + project_id, + project_name, + tags, + updated_at, + use_remote_query_agent, + webpage_url, + ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 7d2408f93..3d6feff5d 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -5,45 +5,45 @@ from .project_item import ProjectItem from .datasource_item import DatasourceItem -logger = logging.getLogger('tableau.models.favorites_item') +logger = logging.getLogger("tableau.models.favorites_item") class FavoriteItem: class Type: - Workbook = 'workbook' - Datasource = 'datasource' - View = 'view' - Project = 'project' + Workbook = "workbook" + Datasource = "datasource" + View = "view" + Project = "project" @classmethod def from_response(cls, xml, namespace): favorites = { - 'datasources': [], - 'projects': [], - 'views': [], - 'workbooks': [], + "datasources": [], + "projects": [], + "views": [], + "workbooks": [], } parsed_response = ET.fromstring(xml) - for workbook in parsed_response.findall('.//t:favorite/t:workbook', namespace): - fav_workbook = WorkbookItem('') + for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace): + fav_workbook = WorkbookItem("") fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) if fav_workbook: - favorites['workbooks'].append(fav_workbook) - for view in parsed_response.findall('.//t:favorite[t:view]', namespace): + favorites["workbooks"].append(fav_workbook) + for view in parsed_response.findall(".//t:favorite[t:view]", namespace): fav_views = ViewItem.from_xml_element(view, namespace) if fav_views: for fav_view in fav_views: - favorites['views'].append(fav_view) - for datasource in parsed_response.findall('.//t:favorite/t:datasource', namespace): - fav_datasource = DatasourceItem('') + favorites["views"].append(fav_view) + for datasource in parsed_response.findall(".//t:favorite/t:datasource", namespace): + fav_datasource = DatasourceItem("") fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) if fav_datasource: - favorites['datasources'].append(fav_datasource) - for project in parsed_response.findall('.//t:favorite/t:project', namespace): - fav_project = ProjectItem('p') + favorites["datasources"].append(fav_datasource) + for project in parsed_response.findall(".//t:favorite/t:project", namespace): + fav_project = ProjectItem("p") fav_project._set_values(*fav_project._parse_element(project)) if fav_project: - favorites['projects'].append(fav_project) + favorites["projects"].append(fav_project) return favorites diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index bd5a3a85f..a697a5aaf 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -17,8 +17,8 @@ def file_size(self): @classmethod def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) - fileupload_elem = parsed_response.find('.//t:fileUpload', namespaces=ns) + fileupload_elem = parsed_response.find(".//t:fileUpload", namespaces=ns) fileupload_item = cls() - fileupload_item._upload_session_id = fileupload_elem.get('uploadSessionId', None) - fileupload_item._file_size = fileupload_elem.get('fileSize', None) + fileupload_item._upload_session_id = fileupload_elem.get("uploadSessionId", None) + fileupload_item._file_size = fileupload_elem.get("fileSize", None) return fileupload_item diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index c978d8175..99e857369 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -26,7 +26,7 @@ def __init__(self, project_id, name=None): @property def connections(self): if self._connections is None: - error = 'Flow item must be populated with connections first.' + error = "Flow item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @@ -86,15 +86,15 @@ def _set_permissions(self, permissions): def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): - flow_xml = ET.fromstring(flow_xml).find('.//t:flow', namespaces=ns) + flow_xml = ET.fromstring(flow_xml).find(".//t:flow", namespaces=ns) if flow_xml is not None: (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id) = self._parse_element(flow_xml, ns) - self._set_values(None, None, None, None, None, updated_at, None, project_id, - project_name, owner_id) + self._set_values(None, None, None, None, None, updated_at, None, project_id, project_name, owner_id) return self - def _set_values(self, id, name, description, webpage_url, created_at, - updated_at, tags, project_id, project_name, owner_id): + def _set_values( + self, id, name, description, webpage_url, created_at, updated_at, tags, project_id, project_name, owner_id + ): if id is not None: self._id = id if name: @@ -121,42 +121,52 @@ def _set_values(self, id, name, description, webpage_url, created_at, def from_response(cls, resp, ns): all_flow_items = list() parsed_response = ET.fromstring(resp) - all_flow_xml = parsed_response.findall('.//t:flow', namespaces=ns) + all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) for flow_xml in all_flow_xml: - (id_, name, description, webpage_url, created_at, updated_at, - tags, project_id, project_name, owner_id) = cls._parse_element(flow_xml, ns) + ( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + project_id, + project_name, + owner_id, + ) = cls._parse_element(flow_xml, ns) flow_item = cls(project_id) - flow_item._set_values(id_, name, description, webpage_url, created_at, updated_at, - tags, None, project_name, owner_id) + flow_item._set_values( + id_, name, description, webpage_url, created_at, updated_at, tags, None, project_name, owner_id + ) all_flow_items.append(flow_item) return all_flow_items @staticmethod def _parse_element(flow_xml, ns): - id_ = flow_xml.get('id', None) - name = flow_xml.get('name', None) - description = flow_xml.get('description', None) - webpage_url = flow_xml.get('webpageUrl', None) - created_at = parse_datetime(flow_xml.get('createdAt', None)) - updated_at = parse_datetime(flow_xml.get('updatedAt', None)) + id_ = flow_xml.get("id", None) + name = flow_xml.get("name", None) + description = flow_xml.get("description", None) + webpage_url = flow_xml.get("webpageUrl", None) + created_at = parse_datetime(flow_xml.get("createdAt", None)) + updated_at = parse_datetime(flow_xml.get("updatedAt", None)) tags = None - tags_elem = flow_xml.find('.//t:tags', namespaces=ns) + tags_elem = flow_xml.find(".//t:tags", namespaces=ns) if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) project_id = None project_name = None - project_elem = flow_xml.find('.//t:project', namespaces=ns) + project_elem = flow_xml.find(".//t:project", namespaces=ns) if project_elem is not None: - project_id = project_elem.get('id', None) - project_name = project_elem.get('name', None) + project_id = project_elem.get("id", None) + project_name = project_elem.get("name", None) owner_id = None - owner_elem = flow_xml.find('.//t:owner', namespaces=ns) + owner_elem = flow_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: - owner_id = owner_elem.get('id', None) + owner_id = owner_elem.get("id", None) - return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id, - project_name, owner_id) + return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id, project_name, owner_id) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index af9465dfb..fdc06604b 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -7,11 +7,11 @@ class GroupItem(object): - tag_name = 'group' + tag_name = "group" class LicenseMode: - onLogin = 'onLogin' - onSync = 'onSync' + onLogin = "onLogin" + onSync = "onSync" def __init__(self, name=None, domain_name=None): self._id = None @@ -78,23 +78,23 @@ def _set_users(self, users): def from_response(cls, resp, ns): all_group_items = list() parsed_response = ET.fromstring(resp) - all_group_xml = parsed_response.findall('.//t:group', namespaces=ns) + all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) for group_xml in all_group_xml: - name = group_xml.get('name', None) + name = group_xml.get("name", None) group_item = cls(name) - group_item._id = group_xml.get('id', None) + group_item._id = group_xml.get("id", None) # Domain name is returned in a domain element for some calls - domain_elem = group_xml.find('.//t:domain', namespaces=ns) + domain_elem = group_xml.find(".//t:domain", namespaces=ns) if domain_elem is not None: - group_item.domain_name = domain_elem.get('name', None) + group_item.domain_name = domain_elem.get("name", None) # Import element is returned for both local and AD groups (2020.3+) - import_elem = group_xml.find('.//t:import', namespaces=ns) + import_elem = group_xml.find(".//t:import", namespaces=ns) if import_elem is not None: - group_item.domain_name = import_elem.get('domainName', None) - group_item.license_mode = import_elem.get('grantLicenseMode', None) - group_item.minimum_site_role = import_elem.get('siteRole', None) + group_item.domain_name = import_elem.get("domainName", None) + group_item.license_mode = import_elem.get("grantLicenseMode", None) + group_item.minimum_site_role = import_elem.get("siteRole", None) all_group_items.append(group_item) return all_group_items diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index cbc148e88..320e01ef2 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -62,7 +62,7 @@ def interval(self): @interval.setter def interval(self, interval): - VALID_INTERVALS = {.25, .5, 1, 2, 4, 6, 8, 12} + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} if float(interval) not in VALID_INTERVALS: error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) @@ -73,7 +73,7 @@ def _interval_type_pairs(self): # We use fractional hours for the two minute-based intervals. # Need to convert to minutes from hours here - if self.interval in {.25, .5}: + if self.interval in {0.25, 0.5}: calculated_interval = int(self.interval * 60) interval_type = IntervalItem.Occurrence.Minutes else: diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 985907ba3..7b7ea4921 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -3,8 +3,18 @@ class JobItem(object): - def __init__(self, id_, job_type, progress, created_at, started_at=None, - completed_at=None, finish_code=0, notes=None, mode=None): + def __init__( + self, + id_, + job_type, + progress, + created_at, + started_at=None, + completed_at=None, + finish_code=0, + notes=None, + mode=None, + ): self._id = id_ self._type = job_type self._progress = progress @@ -57,14 +67,15 @@ def mode(self, value): self._mode = value def __repr__(self): - return "".format(**self.__dict__) + return ( + "".format(**self.__dict__) + ) @classmethod def from_response(cls, xml, ns): parsed_response = ET.fromstring(xml) - all_tasks_xml = parsed_response.findall( - './/t:job', namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) all_tasks = [JobItem._parse_element(x, ns) for x in all_tasks_xml] @@ -72,16 +83,15 @@ def from_response(cls, xml, ns): @classmethod def _parse_element(cls, element, ns): - id_ = element.get('id', None) - type_ = element.get('type', None) - progress = element.get('progress', None) - created_at = parse_datetime(element.get('createdAt', None)) - started_at = parse_datetime(element.get('startedAt', None)) - completed_at = parse_datetime(element.get('completedAt', None)) - finish_code = element.get('finishCode', -1) - notes = [note.text for note in - element.findall('.//t:notes', namespaces=ns)] or None - mode = element.get('mode', None) + id_ = element.get("id", None) + type_ = element.get("type", None) + progress = element.get("progress", None) + created_at = parse_datetime(element.get("createdAt", None)) + started_at = parse_datetime(element.get("startedAt", None)) + completed_at = parse_datetime(element.get("completedAt", None)) + finish_code = element.get("finishCode", -1) + notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + mode = element.get("mode", None) return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes, mode) @@ -93,8 +103,9 @@ class Status: Failed = "Failed" Cancelled = "Cancelled" - def __init__(self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, - ended_at=None): + def __init__( + self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, ended_at=None + ): self._id = id_ self._type = job_type self._status = status @@ -150,20 +161,19 @@ def priority(self): @classmethod def from_response(cls, xml, ns): parsed_response = ET.fromstring(xml) - all_tasks_xml = parsed_response.findall( - './/t:backgroundJob', namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] @classmethod def _parse_element(cls, element, ns): - id_ = element.get('id', None) - type_ = element.get('jobType', None) - status = element.get('status', None) - created_at = parse_datetime(element.get('createdAt', None)) - started_at = parse_datetime(element.get('startedAt', None)) - ended_at = parse_datetime(element.get('endedAt', None)) - priority = element.get('priority', None) - title = element.get('title', None) - subtitle = element.get('subtitle', None) + id_ = element.get("id", None) + type_ = element.get("jobType", None) + status = element.get("status", None) + created_at = parse_datetime(element.get("createdAt", None)) + started_at = parse_datetime(element.get("startedAt", None)) + ended_at = parse_datetime(element.get("endedAt", None)) + priority = element.get("priority", None) + title = element.get("title", None) + subtitle = element.get("subtitle", None) return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index a1f5409e3..df9ca26e6 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -22,12 +22,12 @@ def total_available(self): @classmethod def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) - pagination_xml = parsed_response.find('t:pagination', namespaces=ns) + pagination_xml = parsed_response.find("t:pagination", namespaces=ns) pagination_item = cls() if pagination_xml is not None: - pagination_item._page_number = int(pagination_xml.get('pageNumber', '-1')) - pagination_item._page_size = int(pagination_xml.get('pageSize', '-1')) - pagination_item._total_available = int(pagination_xml.get('totalAvailable', '-1')) + pagination_item._page_number = int(pagination_xml.get("pageNumber", "-1")) + pagination_item._page_size = int(pagination_xml.get("pageSize", "-1")) + pagination_item._total_available = int(pagination_xml.get("totalAvailable", "-1")) return pagination_item @classmethod diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 216315587..113e8525e 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -5,45 +5,43 @@ from .user_item import UserItem from .group_item import GroupItem -logger = logging.getLogger('tableau.models.permissions_item') +logger = logging.getLogger("tableau.models.permissions_item") class Permission: - class Mode: - Allow = 'Allow' - Deny = 'Deny' + Allow = "Allow" + Deny = "Deny" class Capability: - AddComment = 'AddComment' - ChangeHierarchy = 'ChangeHierarchy' - ChangePermissions = 'ChangePermissions' - Connect = 'Connect' - Delete = 'Delete' - Execute = 'Execute' - ExportData = 'ExportData' - ExportImage = 'ExportImage' - ExportXml = 'ExportXml' - Filter = 'Filter' - ProjectLeader = 'ProjectLeader' - Read = 'Read' - ShareView = 'ShareView' - ViewComments = 'ViewComments' - ViewUnderlyingData = 'ViewUnderlyingData' - WebAuthoring = 'WebAuthoring' - Write = 'Write' + AddComment = "AddComment" + ChangeHierarchy = "ChangeHierarchy" + ChangePermissions = "ChangePermissions" + Connect = "Connect" + Delete = "Delete" + Execute = "Execute" + ExportData = "ExportData" + ExportImage = "ExportImage" + ExportXml = "ExportXml" + Filter = "Filter" + ProjectLeader = "ProjectLeader" + Read = "Read" + ShareView = "ShareView" + ViewComments = "ViewComments" + ViewUnderlyingData = "ViewUnderlyingData" + WebAuthoring = "WebAuthoring" + Write = "Write" class Resource: - Workbook = 'workbook' - Datasource = 'datasource' - Flow = 'flow' - Table = 'table' - Database = 'database' - View = 'view' + Workbook = "workbook" + Datasource = "datasource" + Flow = "flow" + Table = "table" + Database = "database" + View = "view" class PermissionsRule(object): - def __init__(self, grantee, capabilities): self.grantee = grantee self.capabilities = capabilities @@ -53,23 +51,20 @@ def from_response(cls, resp, ns=None): parsed_response = ET.fromstring(resp) rules = [] - permissions_rules_list_xml = parsed_response.findall('.//t:granteeCapabilities', - namespaces=ns) + permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: capability_dict = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) - for capability_xml in grantee_capability_xml.findall( - './/t:capabilities/t:capability', namespaces=ns): - name = capability_xml.get('name') - mode = capability_xml.get('mode') + for capability_xml in grantee_capability_xml.findall(".//t:capabilities/t:capability", namespaces=ns): + name = capability_xml.get("name") + mode = capability_xml.get("mode") capability_dict[name] = mode - rule = PermissionsRule(grantee, - capability_dict) + rule = PermissionsRule(grantee, capability_dict) rules.append(rule) return rules @@ -79,17 +74,17 @@ def _parse_grantee_element(grantee_capability_xml, ns): """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute - grantee_element = grantee_capability_xml.findall('.//*[@id]', namespaces=ns).pop() - grantee_id = grantee_element.get('id', None) - grantee_type = grantee_element.tag.split('}').pop() + grantee_element = grantee_capability_xml.findall(".//*[@id]", namespaces=ns).pop() + grantee_id = grantee_element.get("id", None) + grantee_type = grantee_element.tag.split("}").pop() if grantee_id is None: - logger.error('Cannot find grantee type in response') + logger.error("Cannot find grantee type in response") raise UnknownGranteeTypeError() - if grantee_type == 'user': + if grantee_type == "user": grantee = UserItem.as_reference(grantee_id) - elif grantee_type == 'group': + elif grantee_type == "group": grantee = GroupItem.as_reference(grantee_id) else: raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index 13a2391b8..c80a020e8 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -1,5 +1,5 @@ class PersonalAccessTokenAuth(object): - def __init__(self, token_name, personal_access_token, site_id=''): + def __init__(self, token_name, personal_access_token, site_id=""): self.token_name = token_name self.personal_access_token = personal_access_token self.site_id = site_id @@ -8,12 +8,7 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): - return { - 'personalAccessTokenName': self.token_name, - 'personalAccessTokenSecret': self.personal_access_token - } + return {"personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token} def __repr__(self): - return "".format( - self.token_name, self.personal_access_token - ) + return "".format(self.token_name, self.personal_access_token) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 4cfbcb4e9..e8434a0ad 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -8,8 +8,8 @@ class ProjectItem(object): class ContentPermissions: - LockedToProject = 'LockedToProject' - ManagedByOwner = 'ManagedByOwner' + LockedToProject = "LockedToProject" + ManagedByOwner = "ManagedByOwner" def __init__(self, name, description=None, content_permissions=None, parent_id=None): self._content_permissions = None @@ -80,14 +80,14 @@ def owner_id(self): @owner_id.setter def owner_id(self, value): - raise NotImplementedError('REST API does not currently support updating project owner.') + raise NotImplementedError("REST API does not currently support updating project owner.") def is_default(self): - return self.name.lower() == 'default' + return self.name.lower() == "default" def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find('.//t:project', namespaces=ns) + project_xml = ET.fromstring(project_xml).find(".//t:project", namespaces=ns) if project_xml is not None: (_, name, description, content_permissions, parent_id) = self._parse_element(project_xml) @@ -118,7 +118,7 @@ def _set_default_permissions(self, permissions, content_type): def from_response(cls, resp, ns): all_project_items = list() parsed_response = ET.fromstring(resp) - all_project_xml = parsed_response.findall('.//t:project', namespaces=ns) + all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: (id, name, description, content_permissions, parent_id, owner_id) = cls._parse_element(project_xml) @@ -129,13 +129,13 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(project_xml): - id = project_xml.get('id', None) - name = project_xml.get('name', None) - description = project_xml.get('description', None) - content_permissions = project_xml.get('contentPermissions', None) - parent_id = project_xml.get('parentProjectId', None) + id = project_xml.get("id", None) + name = project_xml.get("name", None) + description = project_xml.get("description", None) + content_permissions = project_xml.get("contentPermissions", None) + parent_id = project_xml.get("parentProjectId", None) owner_id = None for owner in project_xml: - owner_id = owner.get('id', None) + owner_id = owner.get("id", None) return id, name, description, content_permissions, parent_id, owner_id diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index f1625d112..153786d4c 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,6 +2,7 @@ import re from functools import wraps from ..datetime_helpers import parse_datetime + try: basestring except NameError: @@ -70,13 +71,13 @@ def wrapper(self, value): def property_is_int(range, allowed=None): - '''Takes a range of ints and a list of exemptions to check against + """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. This is useful for when we use sentinel values. Example: Revisions allow a range of 2-10000, but use -1 as a sentinel for 'unlimited'. - ''' + """ if allowed is None: allowed = () # Empty tuple for fast no-op testing. @@ -98,7 +99,9 @@ def wrapper(self, value): raise ValueError(error) return func(self, value) + return wrapper + return property_type_decorator @@ -112,12 +115,14 @@ def validate_regex_decorator(self, value): if not compiled_re.match(value): raise ValueError(error) return func(self, value) + return validate_regex_decorator + return wrapper def property_is_datetime(func): - """ Takes the following datetime format and turns it into a datetime object: + """Takes the following datetime format and turns it into a datetime object: 2016-08-18T18:25:36Z @@ -130,11 +135,13 @@ def wrapper(self, value): if isinstance(value, datetime.datetime): return func(self, value) if not isinstance(value, basestring): - raise ValueError("Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, - func.__name__)) + raise ValueError( + "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + ) dt = parse_datetime(value) return func(self, dt) + return wrapper @@ -142,15 +149,15 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, - func.__name__)) - if len(value) != 4 or not all(attr in value.keys() for attr in ('acceleration_enabled', - 'accelerate_now', - 'last_updated_at', - 'acceleration_status')): + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + if len(value) != 4 or not all( + attr in value.keys() + for attr in ("acceleration_enabled", "accelerate_now", "last_updated_at", "acceleration_status") + ): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) raise ValueError(error) return func(self, value) + return wrapper diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 2cf0f0119..48d2ab56a 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,5 +1,4 @@ class ResourceReference(object): - def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index c93ffe922..b54c20ae9 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -35,7 +35,7 @@ def __init__(self, name, priority, schedule_type, execution_order, interval_item self.schedule_type = schedule_type def __repr__(self): - return "".format(**self.__dict__) + return ''.format(**self.__dict__) @property def created_at(self): @@ -109,27 +109,53 @@ def warnings(self): def _parse_common_tags(self, schedule_xml, ns): if not isinstance(schedule_xml, ET.Element): - schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=ns) + schedule_xml = ET.fromstring(schedule_xml).find(".//t:schedule", namespaces=ns) if schedule_xml is not None: - (_, name, _, _, updated_at, _, next_run_at, end_schedule_at, execution_order, - priority, interval_item) = self._parse_element(schedule_xml, ns) - - self._set_values(id_=None, - name=name, - state=None, - created_at=None, - updated_at=updated_at, - schedule_type=None, - next_run_at=next_run_at, - end_schedule_at=end_schedule_at, - execution_order=execution_order, - priority=priority, - interval_item=interval_item) + ( + _, + name, + _, + _, + updated_at, + _, + next_run_at, + end_schedule_at, + execution_order, + priority, + interval_item, + ) = self._parse_element(schedule_xml, ns) + + self._set_values( + id_=None, + name=name, + state=None, + created_at=None, + updated_at=updated_at, + schedule_type=None, + next_run_at=next_run_at, + end_schedule_at=end_schedule_at, + execution_order=execution_order, + priority=priority, + interval_item=interval_item, + ) return self - def _set_values(self, id_, name, state, created_at, updated_at, schedule_type, - next_run_at, end_schedule_at, execution_order, priority, interval_item, warnings=None): + def _set_values( + self, + id_, + name, + state, + created_at, + updated_at, + schedule_type, + next_run_at, + end_schedule_at, + execution_order, + priority, + interval_item, + warnings=None, + ): if id_ is not None: self._id = id_ if name: @@ -165,25 +191,38 @@ def from_element(cls, parsed_response, ns): warnings = cls._read_warnings(parsed_response, ns) all_schedule_items = [] - all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=ns) + all_schedule_xml = parsed_response.findall(".//t:schedule", namespaces=ns) for schedule_xml in all_schedule_xml: - (id_, name, state, created_at, updated_at, schedule_type, next_run_at, - end_schedule_at, execution_order, priority, interval_item) = cls._parse_element(schedule_xml, ns) + ( + id_, + name, + state, + created_at, + updated_at, + schedule_type, + next_run_at, + end_schedule_at, + execution_order, + priority, + interval_item, + ) = cls._parse_element(schedule_xml, ns) schedule_item = cls(name, priority, schedule_type, execution_order, interval_item) - schedule_item._set_values(id_=id_, - name=None, - state=state, - created_at=created_at, - updated_at=updated_at, - schedule_type=None, - next_run_at=next_run_at, - end_schedule_at=end_schedule_at, - execution_order=None, - priority=None, - interval_item=None, - warnings=warnings) + schedule_item._set_values( + id_=id_, + name=None, + state=state, + created_at=created_at, + updated_at=updated_at, + schedule_type=None, + next_run_at=next_run_at, + end_schedule_at=end_schedule_at, + execution_order=None, + priority=None, + interval_item=None, + warnings=warnings, + ) all_schedule_items.append(schedule_item) return all_schedule_items @@ -223,44 +262,58 @@ def _parse_interval_item(parsed_response, frequency, ns): @staticmethod def _parse_element(schedule_xml, ns): - id = schedule_xml.get('id', None) - name = schedule_xml.get('name', None) - state = schedule_xml.get('state', None) - created_at = parse_datetime(schedule_xml.get('createdAt', None)) - updated_at = parse_datetime(schedule_xml.get('updatedAt', None)) - schedule_type = schedule_xml.get('type', None) - frequency = schedule_xml.get('frequency', None) - next_run_at = parse_datetime(schedule_xml.get('nextRunAt', None)) - end_schedule_at = parse_datetime(schedule_xml.get('endScheduleAt', None)) - execution_order = schedule_xml.get('executionOrder', None) - - priority = schedule_xml.get('priority', None) + id = schedule_xml.get("id", None) + name = schedule_xml.get("name", None) + state = schedule_xml.get("state", None) + created_at = parse_datetime(schedule_xml.get("createdAt", None)) + updated_at = parse_datetime(schedule_xml.get("updatedAt", None)) + schedule_type = schedule_xml.get("type", None) + frequency = schedule_xml.get("frequency", None) + next_run_at = parse_datetime(schedule_xml.get("nextRunAt", None)) + end_schedule_at = parse_datetime(schedule_xml.get("endScheduleAt", None)) + execution_order = schedule_xml.get("executionOrder", None) + + priority = schedule_xml.get("priority", None) if priority: priority = int(priority) interval_item = None - frequency_detail_elem = schedule_xml.find('.//t:frequencyDetails', namespaces=ns) + frequency_detail_elem = schedule_xml.find(".//t:frequencyDetails", namespaces=ns) if frequency_detail_elem is not None: interval_item = ScheduleItem._parse_interval_item(frequency_detail_elem, frequency, ns) - return id, name, state, created_at, updated_at, schedule_type, \ - next_run_at, end_schedule_at, execution_order, priority, interval_item + return ( + id, + name, + state, + created_at, + updated_at, + schedule_type, + next_run_at, + end_schedule_at, + execution_order, + priority, + interval_item, + ) @staticmethod def parse_add_to_schedule_response(response, ns): parsed_response = ET.fromstring(response.content) warnings = ScheduleItem._read_warnings(parsed_response, ns) - all_task_xml = parsed_response.findall('.//t:task', namespaces=ns) + all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) - error = "Status {}: {}".format(response.status_code, response.reason) \ - if response.status_code < 200 or response.status_code >= 300 else None + error = ( + "Status {}: {}".format(response.status_code, response.reason) + if response.status_code < 200 or response.status_code >= 300 + else None + ) task_created = len(all_task_xml) > 0 return error, warnings, task_created @staticmethod def _read_warnings(parsed_response, ns): - all_warning_xml = parsed_response.findall('.//t:warning', namespaces=ns) + all_warning_xml = parsed_response.findall(".//t:warning", namespaces=ns) warnings = list() if len(all_warning_xml) > 0 else None for warning_xml in all_warning_xml: - warnings.append(warning_xml.get('message', None)) + warnings.append(warning_xml.get("message", None)) return warnings diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 0fcdb1e1e..1f6604662 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -22,10 +22,10 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): parsed_response = ET.fromstring(resp) - product_version_tag = parsed_response.find('.//t:productVersion', namespaces=ns) - rest_api_version_tag = parsed_response.find('.//t:restApiVersion', namespaces=ns) + product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) + rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) - build_number = product_version_tag.get('build', None) + build_number = product_version_tag.get("build", None) product_version = product_version_tag.text rest_api_version = rest_api_version_tag.text diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index f562289ce..ac20e6a89 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,6 +1,12 @@ import xml.etree.ElementTree as ET -from .property_decorators import (property_is_enum, property_is_boolean, property_matches, - property_not_empty, property_not_nullable, property_is_int) +from .property_decorators import ( + property_is_enum, + property_is_boolean, + property_matches, + property_not_empty, + property_not_nullable, + property_is_int, +) VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" @@ -8,28 +14,62 @@ class SiteItem(object): class AdminMode: - ContentAndUsers = 'ContentAndUsers' - ContentOnly = 'ContentOnly' + ContentAndUsers = "ContentAndUsers" + ContentOnly = "ContentOnly" class State: - Active = 'Active' - Suspended = 'Suspended' - - def __init__(self, name, content_url, admin_mode=None, user_quota=None, storage_quota=None, - disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, data_acceleration_mode=None, flows_enabled=True, cataloging_enabled=True, - editing_flows_enabled=True, scheduling_flows_enabled=True, allow_subscription_attachments=True, - guest_access_enabled=False, cache_warmup_enabled=True, commenting_enabled=True, - extract_encryption_mode=None, request_access_enabled=False, run_now_enabled=True, - tier_explorer_capacity=None, tier_creator_capacity=None, tier_viewer_capacity=None, - data_alerts_enabled=True, commenting_mentions_enabled=True, catalog_obfuscation_enabled=False, - flow_auto_save_enabled=True, web_extraction_enabled=True, metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, authoring_enabled=True, custom_subscription_email_enabled=False, - custom_subscription_email=False, custom_subscription_footer_enabled=False, - custom_subscription_footer=False, ask_data_mode='EnabledByDefault', named_sharing_enabled=True, - mobile_biometrics_enabled=False, sheet_image_enabled=True, derived_permissions_enabled=False, - user_visibility_mode='FULL', use_default_time_zone=True, time_zone=None, - auto_suspend_refresh_enabled=True, auto_suspend_refresh_inactivity_window=30): + Active = "Active" + Suspended = "Suspended" + + def __init__( + self, + name, + content_url, + admin_mode=None, + user_quota=None, + storage_quota=None, + disable_subscriptions=False, + subscribe_others_enabled=True, + revision_history_enabled=False, + revision_limit=None, + data_acceleration_mode=None, + flows_enabled=True, + cataloging_enabled=True, + editing_flows_enabled=True, + scheduling_flows_enabled=True, + allow_subscription_attachments=True, + guest_access_enabled=False, + cache_warmup_enabled=True, + commenting_enabled=True, + extract_encryption_mode=None, + request_access_enabled=False, + run_now_enabled=True, + tier_explorer_capacity=None, + tier_creator_capacity=None, + tier_viewer_capacity=None, + data_alerts_enabled=True, + commenting_mentions_enabled=True, + catalog_obfuscation_enabled=False, + flow_auto_save_enabled=True, + web_extraction_enabled=True, + metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, + authoring_enabled=True, + custom_subscription_email_enabled=False, + custom_subscription_email=False, + custom_subscription_footer_enabled=False, + custom_subscription_footer=False, + ask_data_mode="EnabledByDefault", + named_sharing_enabled=True, + mobile_biometrics_enabled=False, + sheet_image_enabled=True, + derived_permissions_enabled=False, + user_visibility_mode="FULL", + use_default_time_zone=True, + time_zone=None, + auto_suspend_refresh_enabled=True, + auto_suspend_refresh_inactivity_window=30, + ): self._admin_mode = None self._id = None self._num_users = None @@ -198,7 +238,7 @@ def flows_enabled(self, value): self._flows_enabled = value def is_default(self): - return self.name.lower() == 'default' + return self.name.lower() == "default" @property def editing_flows_enabled(self): @@ -496,51 +536,171 @@ def auto_suspend_refresh_enabled(self, value): def _parse_common_tags(self, site_xml, ns): if not isinstance(site_xml, ET.Element): - site_xml = ET.fromstring(site_xml).find('.//t:site', namespaces=ns) + site_xml = ET.fromstring(site_xml).find(".//t:site", namespaces=ns) if site_xml is not None: - (_, name, content_url, _, admin_mode, state, - subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, - data_acceleration_mode, flows_enabled, cataloging_enabled, editing_flows_enabled, - scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled, - cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, - run_now_enabled, tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, - commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled, - metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled, - custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled, - custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled, - sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone, - auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window) = self._parse_element(site_xml, ns) - - self._set_values(None, name, content_url, None, admin_mode, state, subscribe_others_enabled, - disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, - cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, - allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, - commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled, - tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, - commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, - web_extraction_enabled, metrics_content_type_enabled, notify_site_admins_on_throttle, - authoring_enabled, custom_subscription_email_enabled, custom_subscription_email, - custom_subscription_footer_enabled, custom_subscription_footer, ask_data_mode, - named_sharing_enabled, mobile_biometrics_enabled, sheet_image_enabled, - derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone, - auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window) + ( + _, + name, + content_url, + _, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ) = self._parse_element(site_xml, ns) + + self._set_values( + None, + name, + content_url, + None, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ) return self - def _set_values(self, id, name, content_url, status_reason, admin_mode, state, - subscribe_others_enabled, disable_subscriptions, revision_history_enabled, - user_quota, storage_quota, revision_limit, num_users, storage, data_acceleration_mode, - flows_enabled, cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, - allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, commenting_enabled, - extract_encryption_mode, request_access_enabled, run_now_enabled, tier_explorer_capacity, - tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled, commenting_mentions_enabled, - catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled, - metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled, - custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled, - custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled, - sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, - time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window): + def _set_values( + self, + id, + name, + content_url, + status_reason, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ): if id is not None: self._id = id if name: @@ -648,133 +808,252 @@ def _set_values(self, id, name, content_url, status_reason, admin_mode, state, def from_response(cls, resp, ns): all_site_items = list() parsed_response = ET.fromstring(resp) - all_site_xml = parsed_response.findall('.//t:site', namespaces=ns) + all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) for site_xml in all_site_xml: - (id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, - disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled, - editing_flows_enabled, scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled, - cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, - run_now_enabled, tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, - data_alerts_enabled, commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, - web_extraction_enabled, metrics_content_type_enabled, notify_site_admins_on_throttle, - authoring_enabled, custom_subscription_email_enabled, custom_subscription_email, - custom_subscription_footer_enabled, custom_subscription_footer, ask_data_mode, named_sharing_enabled, - mobile_biometrics_enabled, sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, - use_default_time_zone, time_zone, auto_suspend_refresh_enabled, - auto_suspend_refresh_inactivity_window) = cls._parse_element(site_xml, ns) + ( + id, + name, + content_url, + status_reason, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) - site_item._set_values(id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled, - disable_subscriptions, revision_history_enabled, user_quota, storage_quota, - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, - cataloging_enabled, editing_flows_enabled, scheduling_flows_enabled, - allow_subscription_attachments, guest_access_enabled, cache_warmup_enabled, - commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled, - tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, - data_alerts_enabled, commenting_mentions_enabled, catalog_obfuscation_enabled, - flow_auto_save_enabled, web_extraction_enabled, metrics_content_type_enabled, - notify_site_admins_on_throttle, authoring_enabled, custom_subscription_email_enabled, - custom_subscription_email, custom_subscription_footer_enabled, - custom_subscription_footer, ask_data_mode, named_sharing_enabled, - mobile_biometrics_enabled, sheet_image_enabled, derived_permissions_enabled, - user_visibility_mode, use_default_time_zone, time_zone, auto_suspend_refresh_enabled, - auto_suspend_refresh_inactivity_window) + site_item._set_values( + id, + name, + content_url, + status_reason, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ) all_site_items.append(site_item) return all_site_items @staticmethod def _parse_element(site_xml, ns): - id = site_xml.get('id', None) - name = site_xml.get('name', None) - content_url = site_xml.get('contentUrl', None) - status_reason = site_xml.get('statusReason', None) - admin_mode = site_xml.get('adminMode', None) - state = site_xml.get('state', None) - subscribe_others_enabled = string_to_bool(site_xml.get('subscribeOthersEnabled', '')) - disable_subscriptions = string_to_bool(site_xml.get('disableSubscriptions', '')) - revision_history_enabled = string_to_bool(site_xml.get('revisionHistoryEnabled', '')) - editing_flows_enabled = string_to_bool(site_xml.get('editingFlowsEnabled', '')) - scheduling_flows_enabled = string_to_bool(site_xml.get('schedulingFlowsEnabled', '')) - allow_subscription_attachments = string_to_bool(site_xml.get('allowSubscriptionAttachments', '')) - guest_access_enabled = string_to_bool(site_xml.get('guestAccessEnabled', '')) - cache_warmup_enabled = string_to_bool(site_xml.get('cacheWarmupEnabled', '')) - commenting_enabled = string_to_bool(site_xml.get('commentingEnabled', '')) - extract_encryption_mode = site_xml.get('extractEncryptionMode', None) - request_access_enabled = string_to_bool(site_xml.get('requestAccessEnabled', '')) - run_now_enabled = string_to_bool(site_xml.get('runNowEnabled', '')) - tier_explorer_capacity = site_xml.get('tierExplorerCapacity', None) + id = site_xml.get("id", None) + name = site_xml.get("name", None) + content_url = site_xml.get("contentUrl", None) + status_reason = site_xml.get("statusReason", None) + admin_mode = site_xml.get("adminMode", None) + state = site_xml.get("state", None) + subscribe_others_enabled = string_to_bool(site_xml.get("subscribeOthersEnabled", "")) + disable_subscriptions = string_to_bool(site_xml.get("disableSubscriptions", "")) + revision_history_enabled = string_to_bool(site_xml.get("revisionHistoryEnabled", "")) + editing_flows_enabled = string_to_bool(site_xml.get("editingFlowsEnabled", "")) + scheduling_flows_enabled = string_to_bool(site_xml.get("schedulingFlowsEnabled", "")) + allow_subscription_attachments = string_to_bool(site_xml.get("allowSubscriptionAttachments", "")) + guest_access_enabled = string_to_bool(site_xml.get("guestAccessEnabled", "")) + cache_warmup_enabled = string_to_bool(site_xml.get("cacheWarmupEnabled", "")) + commenting_enabled = string_to_bool(site_xml.get("commentingEnabled", "")) + extract_encryption_mode = site_xml.get("extractEncryptionMode", None) + request_access_enabled = string_to_bool(site_xml.get("requestAccessEnabled", "")) + run_now_enabled = string_to_bool(site_xml.get("runNowEnabled", "")) + tier_explorer_capacity = site_xml.get("tierExplorerCapacity", None) if tier_explorer_capacity: tier_explorer_capacity = int(tier_explorer_capacity) - tier_creator_capacity = site_xml.get('tierCreatorCapacity', None) + tier_creator_capacity = site_xml.get("tierCreatorCapacity", None) if tier_creator_capacity: tier_creator_capacity = int(tier_creator_capacity) - tier_viewer_capacity = site_xml.get('tierViewerCapacity', None) + tier_viewer_capacity = site_xml.get("tierViewerCapacity", None) if tier_viewer_capacity: tier_viewer_capacity = int(tier_viewer_capacity) - data_alerts_enabled = string_to_bool(site_xml.get('dataAlertsEnabled', '')) - commenting_mentions_enabled = string_to_bool(site_xml.get('commentingMentionsEnabled', '')) - catalog_obfuscation_enabled = string_to_bool(site_xml.get('catalogObfuscationEnabled', '')) - flow_auto_save_enabled = string_to_bool(site_xml.get('flowAutoSaveEnabled', '')) - web_extraction_enabled = string_to_bool(site_xml.get('webExtractionEnabled', '')) - metrics_content_type_enabled = string_to_bool(site_xml.get('metricsContentTypeEnabled', '')) - notify_site_admins_on_throttle = string_to_bool(site_xml.get('notifySiteAdminsOnThrottle', '')) - authoring_enabled = string_to_bool(site_xml.get('authoringEnabled', '')) - custom_subscription_email_enabled = string_to_bool(site_xml.get('customSubscriptionEmailEnabled', '')) - custom_subscription_email = site_xml.get('customSubscriptionEmail', None) - custom_subscription_footer_enabled = string_to_bool(site_xml.get('customSubscriptionFooterEnabled', '')) - custom_subscription_footer = site_xml.get('customSubscriptionFooter', None) - ask_data_mode = site_xml.get('askDataMode', None) - named_sharing_enabled = string_to_bool(site_xml.get('namedSharingEnabled', '')) - mobile_biometrics_enabled = string_to_bool(site_xml.get('mobileBiometricsEnabled', '')) - sheet_image_enabled = string_to_bool(site_xml.get('sheetImageEnabled', '')) - derived_permissions_enabled = string_to_bool(site_xml.get('derivedPermissionsEnabled', '')) - user_visibility_mode = site_xml.get('userVisibilityMode', '') - use_default_time_zone = string_to_bool(site_xml.get('useDefaultTimeZone', '')) - time_zone = site_xml.get('timeZone', None) - auto_suspend_refresh_enabled = string_to_bool(site_xml.get('autoSuspendRefreshEnabled', '')) - auto_suspend_refresh_inactivity_window = site_xml.get('autoSuspendRefreshInactivityWindow', None) + data_alerts_enabled = string_to_bool(site_xml.get("dataAlertsEnabled", "")) + commenting_mentions_enabled = string_to_bool(site_xml.get("commentingMentionsEnabled", "")) + catalog_obfuscation_enabled = string_to_bool(site_xml.get("catalogObfuscationEnabled", "")) + flow_auto_save_enabled = string_to_bool(site_xml.get("flowAutoSaveEnabled", "")) + web_extraction_enabled = string_to_bool(site_xml.get("webExtractionEnabled", "")) + metrics_content_type_enabled = string_to_bool(site_xml.get("metricsContentTypeEnabled", "")) + notify_site_admins_on_throttle = string_to_bool(site_xml.get("notifySiteAdminsOnThrottle", "")) + authoring_enabled = string_to_bool(site_xml.get("authoringEnabled", "")) + custom_subscription_email_enabled = string_to_bool(site_xml.get("customSubscriptionEmailEnabled", "")) + custom_subscription_email = site_xml.get("customSubscriptionEmail", None) + custom_subscription_footer_enabled = string_to_bool(site_xml.get("customSubscriptionFooterEnabled", "")) + custom_subscription_footer = site_xml.get("customSubscriptionFooter", None) + ask_data_mode = site_xml.get("askDataMode", None) + named_sharing_enabled = string_to_bool(site_xml.get("namedSharingEnabled", "")) + mobile_biometrics_enabled = string_to_bool(site_xml.get("mobileBiometricsEnabled", "")) + sheet_image_enabled = string_to_bool(site_xml.get("sheetImageEnabled", "")) + derived_permissions_enabled = string_to_bool(site_xml.get("derivedPermissionsEnabled", "")) + user_visibility_mode = site_xml.get("userVisibilityMode", "") + use_default_time_zone = string_to_bool(site_xml.get("useDefaultTimeZone", "")) + time_zone = site_xml.get("timeZone", None) + auto_suspend_refresh_enabled = string_to_bool(site_xml.get("autoSuspendRefreshEnabled", "")) + auto_suspend_refresh_inactivity_window = site_xml.get("autoSuspendRefreshInactivityWindow", None) if auto_suspend_refresh_inactivity_window: auto_suspend_refresh_inactivity_window = int(auto_suspend_refresh_inactivity_window) - user_quota = site_xml.get('userQuota', None) + user_quota = site_xml.get("userQuota", None) if user_quota: user_quota = int(user_quota) - storage_quota = site_xml.get('storageQuota', None) + storage_quota = site_xml.get("storageQuota", None) if storage_quota: storage_quota = int(storage_quota) - revision_limit = site_xml.get('revisionLimit', None) + revision_limit = site_xml.get("revisionLimit", None) if revision_limit: revision_limit = int(revision_limit) num_users = None storage = None - usage_elem = site_xml.find('.//t:usage', namespaces=ns) + usage_elem = site_xml.find(".//t:usage", namespaces=ns) if usage_elem is not None: - num_users = usage_elem.get('numUsers', None) - storage = usage_elem.get('storage', None) - - data_acceleration_mode = site_xml.get('dataAccelerationMode', '') - - flows_enabled = string_to_bool(site_xml.get('flowsEnabled', '')) - cataloging_enabled = string_to_bool(site_xml.get('catalogingEnabled', '')) - - return id, name, content_url, status_reason, admin_mode, state, subscribe_others_enabled,\ - disable_subscriptions, revision_history_enabled, user_quota, storage_quota,\ - revision_limit, num_users, storage, data_acceleration_mode, flows_enabled, cataloging_enabled,\ - editing_flows_enabled, scheduling_flows_enabled, allow_subscription_attachments, guest_access_enabled,\ - cache_warmup_enabled, commenting_enabled, extract_encryption_mode, request_access_enabled, run_now_enabled,\ - tier_explorer_capacity, tier_creator_capacity, tier_viewer_capacity, data_alerts_enabled,\ - commenting_mentions_enabled, catalog_obfuscation_enabled, flow_auto_save_enabled, web_extraction_enabled,\ - metrics_content_type_enabled, notify_site_admins_on_throttle, authoring_enabled,\ - custom_subscription_email_enabled, custom_subscription_email, custom_subscription_footer_enabled,\ - custom_subscription_footer, ask_data_mode, named_sharing_enabled, mobile_biometrics_enabled,\ - sheet_image_enabled, derived_permissions_enabled, user_visibility_mode, use_default_time_zone, time_zone,\ - auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window + num_users = usage_elem.get("numUsers", None) + storage = usage_elem.get("storage", None) + + data_acceleration_mode = site_xml.get("dataAccelerationMode", "") + + flows_enabled = string_to_bool(site_xml.get("flowsEnabled", "")) + cataloging_enabled = string_to_bool(site_xml.get("catalogingEnabled", "")) + + return ( + id, + name, + content_url, + status_reason, + admin_mode, + state, + subscribe_others_enabled, + disable_subscriptions, + revision_history_enabled, + user_quota, + storage_quota, + revision_limit, + num_users, + storage, + data_acceleration_mode, + flows_enabled, + cataloging_enabled, + editing_flows_enabled, + scheduling_flows_enabled, + allow_subscription_attachments, + guest_access_enabled, + cache_warmup_enabled, + commenting_enabled, + extract_encryption_mode, + request_access_enabled, + run_now_enabled, + tier_explorer_capacity, + tier_creator_capacity, + tier_viewer_capacity, + data_alerts_enabled, + commenting_mentions_enabled, + catalog_obfuscation_enabled, + flow_auto_save_enabled, + web_extraction_enabled, + metrics_content_type_enabled, + notify_site_admins_on_throttle, + authoring_enabled, + custom_subscription_email_enabled, + custom_subscription_email, + custom_subscription_footer_enabled, + custom_subscription_footer, + ask_data_mode, + named_sharing_enabled, + mobile_biometrics_enabled, + sheet_image_enabled, + derived_permissions_enabled, + user_visibility_mode, + use_default_time_zone, + time_zone, + auto_suspend_refresh_enabled, + auto_suspend_refresh_inactivity_window, + ) # Used to convert string represented boolean to a boolean type def string_to_bool(s): - return s.lower() == 'true' + return s.lower() == "true" diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index cdcc468a1..c5ac10168 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -4,7 +4,6 @@ class SubscriptionItem(object): - def __init__(self, subject, schedule_id, user_id, target): self._id = None self.attach_image = True @@ -22,10 +21,14 @@ def __init__(self, subject, schedule_id, user_id, target): def __repr__(self): if self.id is not None: return "".format(**self.__dict__) + return ( + "".format(**self.__dict__) + ) @classmethod def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): parsed_response = ET.fromstring(xml) - all_tasks_xml = parsed_response.findall( - './/t:task/t:{}'.format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) @@ -43,9 +52,9 @@ def _parse_element(cls, element, ns): schedule_item = None target = None last_run_at = None - workbook_element = element.find('.//t:workbook', namespaces=ns) - datasource_element = element.find('.//t:datasource', namespaces=ns) - last_run_at_element = element.find('.//t:lastRunAt', namespaces=ns) + workbook_element = element.find(".//t:workbook", namespaces=ns) + datasource_element = element.find(".//t:datasource", namespaces=ns) + last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns) schedule_item_list = ScheduleItem.from_element(element, ns) if len(schedule_item_list) >= 1: @@ -54,22 +63,23 @@ def _parse_element(cls, element, ns): # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource if workbook_element is not None: - workbook_id = workbook_element.get('id', None) + workbook_id = workbook_element.get("id", None) target = Target(workbook_id, "workbook") if datasource_element is not None: - datasource_id = datasource_element.get('id', None) + datasource_id = datasource_element.get("id", None) target = Target(datasource_id, "datasource") if last_run_at_element is not None: last_run_at = parse_datetime(last_run_at_element.text) # Server response has different names for task types - task_type = cls._translate_task_type(element.get('type', None)) + task_type = cls._translate_task_type(element.get("type", None)) - priority = int(element.get('priority', -1)) - consecutive_failed_count = int(element.get('consecutiveFailedCount', 0)) - id_ = element.get('id', None) - return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id, - schedule_item, last_run_at, target) + priority = int(element.get("priority", -1)) + consecutive_failed_count = int(element.get("consecutiveFailedCount", 0)) + id_ = element.get("id", None) + return cls( + id_, task_type, priority, consecutive_failed_count, schedule_item.id, schedule_item, last_run_at, target + ) @staticmethod def _translate_task_type(task_type): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b5a05b0d1..b9796cbae 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,33 +7,33 @@ class UserItem(object): - tag_name = 'user' + tag_name = "user" class Roles: - Interactor = 'Interactor' - Publisher = 'Publisher' - ServerAdministrator = 'ServerAdministrator' - SiteAdministrator = 'SiteAdministrator' - Unlicensed = 'Unlicensed' - UnlicensedWithPublish = 'UnlicensedWithPublish' - Viewer = 'Viewer' - ViewerWithPublish = 'ViewerWithPublish' - Guest = 'Guest' - - Creator = 'Creator' - Explorer = 'Explorer' - ExplorerCanPublish = 'ExplorerCanPublish' - ReadOnly = 'ReadOnly' - SiteAdministratorCreator = 'SiteAdministratorCreator' - SiteAdministratorExplorer = 'SiteAdministratorExplorer' + Interactor = "Interactor" + Publisher = "Publisher" + ServerAdministrator = "ServerAdministrator" + SiteAdministrator = "SiteAdministrator" + Unlicensed = "Unlicensed" + UnlicensedWithPublish = "UnlicensedWithPublish" + Viewer = "Viewer" + ViewerWithPublish = "ViewerWithPublish" + Guest = "Guest" + + Creator = "Creator" + Explorer = "Explorer" + ExplorerCanPublish = "ExplorerCanPublish" + ReadOnly = "ReadOnly" + SiteAdministratorCreator = "SiteAdministratorCreator" + SiteAdministratorExplorer = "SiteAdministratorExplorer" # Online only - SupportUser = 'SupportUser' + SupportUser = "SupportUser" class Auth: - OpenID = 'OpenID' - SAML = 'SAML' - ServerDefault = 'ServerDefault' + OpenID = "OpenID" + SAML = "SAML" + ServerDefault = "ServerDefault" def __init__(self, name=None, site_role=None, auth_setting=None): self._auth_setting = None @@ -126,14 +126,15 @@ def _set_groups(self, groups): def _parse_common_tags(self, user_xml, ns): if not isinstance(user_xml, ET.Element): - user_xml = ET.fromstring(user_xml).find('.//t:user', namespaces=ns) + user_xml = ET.fromstring(user_xml).find(".//t:user", namespaces=ns) if user_xml is not None: (_, _, site_role, _, _, fullname, email, auth_setting, _) = self._parse_element(user_xml, ns) self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) return self - def _set_values(self, id, name, site_role, last_login, - external_auth_user_id, fullname, email, auth_setting, domain_name): + def _set_values( + self, id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + ): if id is not None: self._id = id if name: @@ -157,13 +158,23 @@ def _set_values(self, id, name, site_role, last_login, def from_response(cls, resp, ns): all_user_items = [] parsed_response = ET.fromstring(resp) - all_user_xml = parsed_response.findall('.//t:user', namespaces=ns) + all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) for user_xml in all_user_xml: - (id, name, site_role, last_login, external_auth_user_id, - fullname, email, auth_setting, domain_name) = cls._parse_element(user_xml, ns) + ( + id, + name, + site_role, + last_login, + external_auth_user_id, + fullname, + email, + auth_setting, + domain_name, + ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) - user_item._set_values(id, name, site_role, last_login, external_auth_user_id, - fullname, email, auth_setting, domain_name) + user_item._set_values( + id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + ) all_user_items.append(user_item) return all_user_items @@ -173,19 +184,19 @@ def as_reference(id_): @staticmethod def _parse_element(user_xml, ns): - id = user_xml.get('id', None) - name = user_xml.get('name', None) - site_role = user_xml.get('siteRole', None) - last_login = parse_datetime(user_xml.get('lastLogin', None)) - external_auth_user_id = user_xml.get('externalAuthUserId', None) - fullname = user_xml.get('fullName', None) - email = user_xml.get('email', None) - auth_setting = user_xml.get('authSetting', None) + id = user_xml.get("id", None) + name = user_xml.get("name", None) + site_role = user_xml.get("siteRole", None) + last_login = parse_datetime(user_xml.get("lastLogin", None)) + external_auth_user_id = user_xml.get("externalAuthUserId", None) + fullname = user_xml.get("fullName", None) + email = user_xml.get("email", None) + auth_setting = user_xml.get("authSetting", None) domain_name = None - domain_elem = user_xml.find('.//t:domain', namespaces=ns) + domain_elem = user_xml.find(".//t:domain", namespaces=ns) if domain_elem is not None: - domain_name = domain_elem.get('name', None) + domain_name = domain_elem.get("name", None) return id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index f9b7a2940..f18acfc33 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -119,42 +119,42 @@ def _set_permissions(self, permissions): self._permissions = permissions @classmethod - def from_response(cls, resp, ns, workbook_id=''): + def from_response(cls, resp, ns, workbook_id=""): return cls.from_xml_element(ET.fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id=''): + def from_xml_element(cls, parsed_response, ns, workbook_id=""): all_view_items = list() - all_view_xml = parsed_response.findall('.//t:view', namespaces=ns) + all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: view_item = cls() - usage_elem = view_xml.find('.//t:usage', namespaces=ns) - workbook_elem = view_xml.find('.//t:workbook', namespaces=ns) - owner_elem = view_xml.find('.//t:owner', namespaces=ns) - project_elem = view_xml.find('.//t:project', namespaces=ns) - tags_elem = view_xml.find('.//t:tags', namespaces=ns) - view_item._created_at = parse_datetime(view_xml.get('createdAt', None)) - view_item._updated_at = parse_datetime(view_xml.get('updatedAt', None)) - view_item._id = view_xml.get('id', None) - view_item._name = view_xml.get('name', None) - view_item._content_url = view_xml.get('contentUrl', None) - view_item._sheet_type = view_xml.get('sheetType', None) + usage_elem = view_xml.find(".//t:usage", namespaces=ns) + workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) + owner_elem = view_xml.find(".//t:owner", namespaces=ns) + project_elem = view_xml.find(".//t:project", namespaces=ns) + tags_elem = view_xml.find(".//t:tags", namespaces=ns) + view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) + view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) + view_item._id = view_xml.get("id", None) + view_item._name = view_xml.get("name", None) + view_item._content_url = view_xml.get("contentUrl", None) + view_item._sheet_type = view_xml.get("sheetType", None) if usage_elem is not None: - total_view = usage_elem.get('totalViewCount', None) + total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: - view_item._owner_id = owner_elem.get('id', None) + view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get('id', None) + view_item._project_id = project_elem.get("id", None) if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get('id', None) + view_item._workbook_id = workbook_elem.get("id", None) if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 57bcfeaa4..5fc5c5749 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -3,13 +3,13 @@ import re -NAMESPACE_RE = re.compile(r'^{.*}') +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) + return NAMESPACE_RE.sub("", event.tag) class WebhookItem(object): @@ -50,7 +50,7 @@ def event(self, value): 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) + all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) for webhook_xml in all_webhooks_xml: values = cls._parse_element(webhook_xml, ns) @@ -61,25 +61,24 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(webhook_xml, ns): - id = webhook_xml.get('id', None) - name = webhook_xml.get('name', None) + 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) + url_tag = webhook_xml.find(".//t:webhook-destination-http", namespaces=ns) if url_tag is not None: - url = url_tag.get('url', None) + url = url_tag.get("url", None) - event = webhook_xml.findall('.//t:webhook-source/*', namespaces=ns) + 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) + owner_tag = webhook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: - owner_id = owner_tag.get('id', 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) + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 3a3ddcdf9..20597364e 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -28,10 +28,12 @@ def __init__(self, project_id, name=None, show_tabs=False): self.project_id = project_id self.show_tabs = show_tabs self.tags = set() - self.data_acceleration_config = {'acceleration_enabled': None, - 'accelerate_now': None, - 'last_updated_at': None, - 'acceleration_status': None} + self.data_acceleration_config = { + "acceleration_enabled": None, + "accelerate_now": None, + "last_updated_at": None, + "acceleration_status": None, + } self._permissions = None @property @@ -155,21 +157,64 @@ def _set_preview_image(self, preview_image): def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): - workbook_xml = ET.fromstring(workbook_xml).find('.//t:workbook', namespaces=ns) + workbook_xml = ET.fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) if workbook_xml is not None: - (_, _, _, _, _, description, updated_at, _, show_tabs, - project_id, project_name, owner_id, _, _, - data_acceleration_config) = self._parse_element(workbook_xml, ns) - - self._set_values(None, None, None, None, None, description, updated_at, - None, show_tabs, project_id, project_name, owner_id, None, None, - data_acceleration_config) + ( + _, + _, + _, + _, + _, + description, + updated_at, + _, + show_tabs, + project_id, + project_name, + owner_id, + _, + _, + data_acceleration_config, + ) = self._parse_element(workbook_xml, ns) + + self._set_values( + None, + None, + None, + None, + None, + description, + updated_at, + None, + show_tabs, + project_id, + project_name, + owner_id, + None, + None, + data_acceleration_config, + ) return self - def _set_values(self, id, name, content_url, webpage_url, created_at, description, updated_at, - size, show_tabs, project_id, project_name, owner_id, tags, views, - data_acceleration_config): + def _set_values( + self, + id, + name, + content_url, + webpage_url, + created_at, + description, + updated_at, + size, + show_tabs, + project_id, + project_name, + owner_id, + tags, + views, + data_acceleration_config, + ): if id is not None: self._id = id if name: @@ -206,92 +251,139 @@ def _set_values(self, id, name, content_url, webpage_url, created_at, descriptio def from_response(cls, resp, ns): all_workbook_items = list() parsed_response = ET.fromstring(resp) - all_workbook_xml = parsed_response.findall('.//t:workbook', namespaces=ns) + all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) for workbook_xml in all_workbook_xml: - (id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, - project_id, project_name, owner_id, tags, views, - data_acceleration_config) = cls._parse_element(workbook_xml, ns) + ( + id, + name, + content_url, + webpage_url, + created_at, + description, + updated_at, + size, + show_tabs, + project_id, + project_name, + owner_id, + tags, + views, + data_acceleration_config, + ) = cls._parse_element(workbook_xml, ns) workbook_item = cls(project_id) - workbook_item._set_values(id, name, content_url, webpage_url, created_at, description, updated_at, - size, show_tabs, None, project_name, owner_id, tags, views, - data_acceleration_config) + workbook_item._set_values( + id, + name, + content_url, + webpage_url, + created_at, + description, + updated_at, + size, + show_tabs, + None, + project_name, + owner_id, + tags, + views, + data_acceleration_config, + ) all_workbook_items.append(workbook_item) return all_workbook_items @staticmethod def _parse_element(workbook_xml, ns): - id = workbook_xml.get('id', None) - name = workbook_xml.get('name', None) - content_url = workbook_xml.get('contentUrl', None) - webpage_url = workbook_xml.get('webpageUrl', None) - created_at = parse_datetime(workbook_xml.get('createdAt', None)) - description = workbook_xml.get('description', None) - updated_at = parse_datetime(workbook_xml.get('updatedAt', None)) - - size = workbook_xml.get('size', None) + id = workbook_xml.get("id", None) + name = workbook_xml.get("name", None) + content_url = workbook_xml.get("contentUrl", None) + webpage_url = workbook_xml.get("webpageUrl", None) + created_at = parse_datetime(workbook_xml.get("createdAt", None)) + description = workbook_xml.get("description", None) + updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + + size = workbook_xml.get("size", None) if size: size = int(size) - show_tabs = string_to_bool(workbook_xml.get('showTabs', '')) + show_tabs = string_to_bool(workbook_xml.get("showTabs", "")) project_id = None project_name = None - project_tag = workbook_xml.find('.//t:project', namespaces=ns) + project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: - project_id = project_tag.get('id', None) - project_name = project_tag.get('name', None) + project_id = project_tag.get("id", None) + project_name = project_tag.get("name", None) owner_id = None - owner_tag = workbook_xml.find('.//t:owner', namespaces=ns) + owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: - owner_id = owner_tag.get('id', None) + owner_id = owner_tag.get("id", None) tags = None - tags_elem = workbook_xml.find('.//t:tags', namespaces=ns) + tags_elem = workbook_xml.find(".//t:tags", namespaces=ns) if tags_elem is not None: all_tags = TagItem.from_xml_element(tags_elem, ns) tags = all_tags views = None - views_elem = workbook_xml.find('.//t:views', namespaces=ns) + views_elem = workbook_xml.find(".//t:views", namespaces=ns) if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) - data_acceleration_config = {'acceleration_enabled': None, 'accelerate_now': None, - 'last_updated_at': None, 'acceleration_status': None} - data_acceleration_elem = workbook_xml.find('.//t:dataAccelerationConfig', namespaces=ns) + data_acceleration_config = { + "acceleration_enabled": None, + "accelerate_now": None, + "last_updated_at": None, + "acceleration_status": None, + } + data_acceleration_elem = workbook_xml.find(".//t:dataAccelerationConfig", namespaces=ns) if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) - return id, name, content_url, webpage_url, created_at, description, updated_at, size, show_tabs, \ - project_id, project_name, owner_id, tags, views, data_acceleration_config + return ( + id, + name, + content_url, + webpage_url, + created_at, + description, + updated_at, + size, + show_tabs, + project_id, + project_name, + owner_id, + tags, + views, + data_acceleration_config, + ) def parse_data_acceleration_config(data_acceleration_elem): data_acceleration_config = dict() - acceleration_enabled = data_acceleration_elem.get('accelerationEnabled', None) + acceleration_enabled = data_acceleration_elem.get("accelerationEnabled", None) if acceleration_enabled is not None: acceleration_enabled = string_to_bool(acceleration_enabled) - accelerate_now = data_acceleration_elem.get('accelerateNow', None) + accelerate_now = data_acceleration_elem.get("accelerateNow", None) if accelerate_now is not None: accelerate_now = string_to_bool(accelerate_now) - last_updated_at = data_acceleration_elem.get('lastUpdatedAt', None) + last_updated_at = data_acceleration_elem.get("lastUpdatedAt", None) if last_updated_at is not None: last_updated_at = parse_datetime(last_updated_at) - acceleration_status = data_acceleration_elem.get('accelerationStatus', None) + acceleration_status = data_acceleration_elem.get("accelerationStatus", None) - data_acceleration_config['acceleration_enabled'] = acceleration_enabled - data_acceleration_config['accelerate_now'] = accelerate_now - data_acceleration_config['last_updated_at'] = last_updated_at - data_acceleration_config['acceleration_status'] = acceleration_status + data_acceleration_config["acceleration_enabled"] = acceleration_enabled + data_acceleration_config["accelerate_now"] = accelerate_now + data_acceleration_config["last_updated_at"] = last_updated_at + data_acceleration_config["acceleration_status"] = acceleration_status return data_acceleration_config # Used to convert string represented boolean to a boolean type def string_to_bool(s): - return s.lower() == 'true' + return s.lower() == "true" diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 43717dce2..986a02fb3 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -1,9 +1,9 @@ from xml.etree import ElementTree as ET import re -OLD_NAMESPACE = 'http://tableausoftware.com/api' -NEW_NAMESPACE = 'http://tableau.com/api' -NAMESPACE_RE = re.compile(r'\{(.*?)\}') +OLD_NAMESPACE = "http://tableausoftware.com/api" +NEW_NAMESPACE = "http://tableau.com/api" +NAMESPACE_RE = re.compile(r"\{(.*?)\}") class UnknownNamespaceError(Exception): @@ -12,7 +12,7 @@ class UnknownNamespaceError(Exception): class Namespace(object): def __init__(self): - self._namespace = {'t': NEW_NAMESPACE} + self._namespace = {"t": NEW_NAMESPACE} self._detected = False def __call__(self): @@ -22,7 +22,7 @@ def detect(self, xml): if self._detected: return - if not xml.startswith(b'= FILESIZE_LIMIT: - logger.info('Publishing {0} to server with chunking method (datasource over 64MB)'.format(filename)) + logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) - xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item, - connection_credentials, - connections) + xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( + datasource_item, connection_credentials, connections + ) else: - logger.info('Publishing {0} to server'.format(filename)) + logger.info("Publishing {0} to server".format(filename)) try: - with open(file, 'rb') as f: + with open(file, "rb") as f: file_contents = f.read() except TypeError: file_contents = file.read() - xml_request, content_type = RequestFactory.Datasource.publish_req(datasource_item, - filename, - file_contents, - connection_credentials, - connections) + xml_request, content_type = RequestFactory.Datasource.publish_req( + datasource_item, filename, file_contents, connection_credentials, connections + ) # Send the publishing request to server try: @@ -255,33 +254,36 @@ def publish(self, datasource_item, file, mode, connection_credentials=None, conn if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (JOB_ID: {1}'.format(filename, new_job.id)) + logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource server_response = self.post_request(url, xml_request, content_type) new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id)) + logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource - @api(version='2.0') + @api(version="2.0") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='2.0') + @api(version="2.0") def update_permission(self, item, permission_item): import warnings - warnings.warn('Server.datasources.update_permission is deprecated, ' - 'please use Server.datasources.update_permissions instead.', - DeprecationWarning) + + warnings.warn( + "Server.datasources.update_permission is deprecated, " + "please use Server.datasources.update_permissions instead.", + DeprecationWarning, + ) self._permissions.update(item, permission_item) - @api(version='2.0') + @api(version="2.0") def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) - @api(version='2.0') + @api(version="2.0") def delete_permission(self, item, capability_item): self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index d435a03d6..1cfa41733 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -11,7 +11,7 @@ class _DefaultPermissionsEndpoint(Endpoint): - """ Adds default-permission model to another endpoint + """Adds default-permission model to another endpoint Tableau default-permissions model applies only to databases and projects and then takes an object type in the uri to set the defaults. @@ -28,38 +28,37 @@ def __init__(self, parent_srv, owner_baseurl): self.owner_baseurl = owner_baseurl def update_default_permissions(self, resource, permissions, content_type): - url = '{0}/{1}/default-permissions/{2}'.format(self.owner_baseurl(), resource.id, content_type + 's') + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s") update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) - permissions = PermissionsRule.from_response(response.content, - self.parent_srv.namespace) - logger.info('Updated permissions for resource {0}'.format(resource.id)) + permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) + logger.info("Updated permissions for resource {0}".format(resource.id)) return permissions def delete_default_permission(self, resource, rule, content_type): for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better - url = '{baseurl}/{content_id}/default-permissions/' \ - '{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}' \ - .format( + url = ( + "{baseurl}/{content_id}/default-permissions/" + "{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}".format( baseurl=self.owner_baseurl(), content_id=resource.id, - content_type=content_type + 's', - grantee_type=rule.grantee.tag_name + 's', + content_type=content_type + "s", + grantee_type=rule.grantee.tag_name + "s", grantee_id=rule.grantee.id, cap=capability, - mode=mode) + mode=mode, + ) + ) - logger.debug('Removing {0} permission for capabilty {1}'.format( - mode, capability)) + logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) self.delete_request(url) - logger.info('Deleted permission for {0} {1} item {2}'.format( - rule.grantee.tag_name, - rule.grantee.id, - resource.id)) + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) def populate_default_permissions(self, item, content_type): if not item.id: @@ -70,12 +69,11 @@ def permission_fetcher(): return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info('Populated {0} permissions for item (ID: {1})'.format(item.id, content_type)) + logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type)) def _get_default_permissions(self, item, content_type, req_options=None): url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") server_response = self.get_request(url, req_options) - permissions = PermissionsRule.from_response(server_response.content, - self.parent_srv.namespace) + permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) return permissions diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index dc504242a..92a20b21a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -9,7 +9,7 @@ except ImportError: from distutils.version import LooseVersion as Version -logger = logging.getLogger('tableau.endpoint') +logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -22,9 +22,9 @@ def __init__(self, parent_srv): def _make_common_headers(auth_token, content_type): headers = {} if auth_token is not None: - headers['x-tableau-auth'] = auth_token + headers["x-tableau-auth"] = auth_token if content_type is not None: - headers['content-type'] = content_type + headers["content-type"] = content_type return headers @@ -33,24 +33,23 @@ def _safe_to_log(server_response): """Checks if the server_response content is not xml (eg binary image or zip) and replaces it with a constant """ - ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8') - if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES: - return '[Truncated File Contents]' + ALLOWED_CONTENT_TYPES = ("application/xml", "application/xml;charset=utf-8") + if server_response.headers.get("Content-Type", None) not in ALLOWED_CONTENT_TYPES: + return "[Truncated File Contents]" else: return server_response.content - def _make_request(self, method, url, content=None, auth_token=None, - content_type=None, parameters=None): + def _make_request(self, method, url, content=None, auth_token=None, content_type=None, parameters=None): parameters = parameters or {} parameters.update(self.parent_srv.http_options) - parameters['headers'] = Endpoint._make_common_headers(auth_token, content_type) + parameters["headers"] = Endpoint._make_common_headers(auth_token, content_type) if content is not None: - parameters['data'] = content + parameters["data"] = content - logger.debug(u'request {}, url: {}'.format(method.__name__, url)) + logger.debug(u"request {}, url: {}".format(method.__name__, url)) if content: - logger.debug(u'request content: {}'.format(content[:1000])) + logger.debug(u"request content: {}".format(content[:1000])) server_response = method(url, **parameters) self.parent_srv._namespace.detect(server_response.content) @@ -59,8 +58,11 @@ def _make_request(self, method, url, content=None, auth_token=None, # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. if len(server_response.content) > 0 and server_response.encoding: - logger.debug(u'Server response from {0}:\n\t{1}'.format( - url, server_response.content.decode(server_response.encoding))) + logger.debug( + u"Server response from {0}:\n\t{1}".format( + url, server_response.content.decode(server_response.encoding) + ) + ) return server_response def _check_status(self, server_response): @@ -92,25 +94,31 @@ def get_request(self, url, request_object=None, parameters=None): except EndpointUnavailableError: url = request_object.apply_query_params(url) - return self._make_request(self.parent_srv.session.get, url, - auth_token=self.parent_srv.auth_token, - parameters=parameters) + return self._make_request( + self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, parameters=parameters + ) def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request=None, content_type='text/xml'): - return self._make_request(self.parent_srv.session.put, url, - content=xml_request, - auth_token=self.parent_srv.auth_token, - content_type=content_type) - - def post_request(self, url, xml_request, content_type='text/xml'): - return self._make_request(self.parent_srv.session.post, url, - content=xml_request, - auth_token=self.parent_srv.auth_token, - content_type=content_type) + def put_request(self, url, xml_request=None, content_type="text/xml"): + return self._make_request( + self.parent_srv.session.put, + url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type, + ) + + def post_request(self, url, xml_request, content_type="text/xml"): + return self._make_request( + self.parent_srv.session.post, + url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type, + ) def api(version): @@ -131,12 +139,15 @@ def api(version): >>> def get(self, req_options=None): >>> ... """ + def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): self.parent_srv.assert_at_least_version(version) return func(self, *args, **kwargs) + return wrapper + return _decorator @@ -162,10 +173,12 @@ def parameter_added_in(**params): >>> def download(self, workbook_id, filepath=None, extract_only=False): >>> ... """ + def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): import warnings + server_ver = Version(self.parent_srv.version or "0.0") params_to_check = set(params) & set(kwargs) for p in params_to_check: @@ -174,7 +187,9 @@ def wrapper(self, *args, **kwargs): error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) warnings.warn(error) return func(self, *args, **kwargs) + return wrapper + return _decorator diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 3c9226f0f..9a9a81d77 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -15,9 +15,11 @@ def __str__(self): def from_response(cls, resp, ns): # Check elements exist before .text parsed_response = ET.fromstring(resp) - error_response = cls(parsed_response.find('t:error', namespaces=ns).get('code', ''), - parsed_response.find('.//t:summary', namespaces=ns).text, - parsed_response.find('.//t:detail', namespaces=ns).text) + error_response = cls( + parsed_response.find("t:error", namespaces=ns).get("code", ""), + parsed_response.find(".//t:summary", namespaces=ns).text, + parsed_response.find(".//t:detail", namespaces=ns).text, + ) return error_response @@ -60,4 +62,5 @@ def __init__(self, error_payload): def __str__(self): from pprint import pformat + return pformat(self.error) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index b1a90ba00..459d852e6 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -7,7 +7,7 @@ import logging import copy -logger = logging.getLogger('tableau.endpoint.favorites') +logger = logging.getLogger("tableau.endpoint.favorites") class Favorites(Endpoint): @@ -18,60 +18,60 @@ def baseurl(self): # Gets all favorites @api(version="2.5") def get(self, user_item, req_options=None): - logger.info('Querying all favorites for user {0}'.format(user_item.name)) - url = '{0}/{1}'.format(self.baseurl, user_item.id) + logger.info("Querying all favorites for user {0}".format(user_item.name)) + url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="2.0") def add_favorite_workbook(self, user_item, workbook_item): - url = '{0}/{1}'.format(self.baseurl, user_item.id) + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info('Favorited {0} for user (ID: {1})'.format(workbook_item.name, user_item.id)) + logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") def add_favorite_view(self, user_item, view_item): - url = '{0}/{1}'.format(self.baseurl, user_item.id) + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info('Favorited {0} for user (ID: {1})'.format(view_item.name, user_item.id)) + logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") def add_favorite_datasource(self, user_item, datasource_item): - url = '{0}/{1}'.format(self.baseurl, user_item.id) + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info('Favorited {0} for user (ID: {1})'.format(datasource_item.name, user_item.id)) + logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") def add_favorite_project(self, user_item, project_item): - url = '{0}/{1}'.format(self.baseurl, user_item.id) + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info('Favorited {0} for user (ID: {1})'.format(project_item.name, user_item.id)) + logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="2.0") def delete_favorite_workbook(self, user_item, workbook_item): - url = '{0}/{1}/workbooks/{2}'.format(self.baseurl, user_item.id, workbook_item.id) - logger.info('Removing favorite {0} for user (ID: {1})'.format(workbook_item.id, user_item.id)) + url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item, view_item): - url = '{0}/{1}/views/{2}'.format(self.baseurl, user_item.id, view_item.id) - logger.info('Removing favorite {0} for user (ID: {1})'.format(view_item.id, user_item.id)) + url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item, datasource_item): - url = '{0}/{1}/datasources/{2}'.format(self.baseurl, user_item.id, datasource_item.id) - logger.info('Removing favorite {0} for user (ID: {1})'.format(datasource_item.id, user_item.id)) + url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item, project_item): - url = '{0}/{1}/projects/{2}'.format(self.baseurl, user_item.id, project_item.id) - logger.info('Removing favorite {0} for user (ID: {1})'.format(project_item.id, user_item.id)) + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index c89a595d4..05a3ce17c 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -8,13 +8,13 @@ # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB -logger = logging.getLogger('tableau.endpoint.fileuploads') +logger = logging.getLogger("tableau.endpoint.fileuploads") class Fileuploads(Endpoint): def __init__(self, parent_srv): super(Fileuploads, self).__init__(parent_srv) - self.upload_id = '' + self.upload_id = "" @property def baseurl(self): @@ -23,10 +23,10 @@ def baseurl(self): @api(version="2.0") def initiate(self): url = self.baseurl - server_response = self.post_request(url, '') + server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) self.upload_id = fileupload_item.upload_session_id - logger.info('Initiated file upload session (ID: {0})'.format(self.upload_id)) + logger.info("Initiated file upload session (ID: {0})".format(self.upload_id)) return self.upload_id @api(version="2.0") @@ -36,13 +36,13 @@ def append(self, xml_request, content_type): raise MissingRequiredFieldError(error) url = "{0}/{1}".format(self.baseurl, self.upload_id) server_response = self.put_request(url, xml_request, content_type) - logger.info('Uploading a chunk to session (ID: {0})'.format(self.upload_id)) + logger.info("Uploading a chunk to session (ID: {0})".format(self.upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def read_chunks(self, file): file_opened = False try: - file_content = open(file, 'rb') + file_content = open(file, "rb") file_opened = True except TypeError: file_content = file diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index dfe16f904..41cbe19cd 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -14,11 +14,11 @@ from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB +FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB -ALLOWED_FILE_EXTENSIONS = ['tfl', 'tflx'] +ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger('tableau.endpoint.flows') +logger = logging.getLogger("tableau.endpoint.flows") class Flows(Endpoint): @@ -34,7 +34,7 @@ def baseurl(self): # Get all flows @api(version="3.3") def get(self, req_options=None): - logger.info('Querying all flows on site') + logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -47,7 +47,7 @@ def get_by_id(self, flow_id): if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info('Querying single flow (ID: {0})'.format(flow_id)) + logger.info("Querying single flow (ID: {0})".format(flow_id)) url = "{0}/{1}".format(self.baseurl, flow_id) server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,17 +56,17 @@ def get_by_id(self, flow_id): @api(version="3.3") def populate_connections(self, flow_item): if not flow_item.id: - error = 'Flow item missing ID. Flow must be retrieved from server first.' + error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info('Populated connections for flow (ID: {0})'.format(flow_item.id)) + logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) def _get_flow_connections(self, flow_item, req_options=None): - url = '{0}/{1}/connections'.format(self.baseurl, flow_item.id) + url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -79,7 +79,7 @@ def delete(self, flow_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, flow_id) self.delete_request(url) - logger.info('Deleted single flow (ID: {0})'.format(flow_id)) + logger.info("Deleted single flow (ID: {0})".format(flow_id)) # Download 1 flow by id @api(version="3.3") @@ -89,24 +89,24 @@ def download(self, flow_id, filepath=None): raise ValueError(error) url = "{0}/{1}/content".format(self.baseurl, flow_id) - with closing(self.get_request(url, parameters={'stream': True})) as server_response: - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = to_filename(os.path.basename(params['filename'])) + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) - with open(download_path, 'wb') as f: + with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded flow to {0} (ID: {1})'.format(download_path, flow_id)) + logger.info("Downloaded flow to {0} (ID: {1})".format(download_path, flow_id)) return os.path.abspath(download_path) # Update flow @api(version="3.3") def update(self, flow_item): if not flow_item.id: - error = 'Flow item missing ID. Flow must be retrieved from server first.' + error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) self._resource_tagger.update_tags(self.baseurl, flow_item) @@ -115,7 +115,7 @@ def update(self, flow_item): url = "{0}/{1}".format(self.baseurl, flow_item.id) update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info('Updated flow item (ID: {0})'.format(flow_item.id)) + logger.info("Updated flow item (ID: {0})".format(flow_item.id)) updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -128,8 +128,7 @@ def update_connection(self, flow_item, connection_item): server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Updated flow item (ID: {0} & connection item {1}'.format(flow_item.id, - connection_item.id)) + logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) return connection @api(version="3.3") @@ -147,7 +146,7 @@ def publish(self, flow_item, file_path, mode, connections=None): error = "File path does not lead to an existing file." raise IOError(error) if not mode or not hasattr(self.parent_srv.PublishMode, mode): - error = 'Invalid mode defined.' + error = "Invalid mode defined." raise ValueError(error) filename = os.path.basename(file_path) @@ -157,29 +156,25 @@ def publish(self, flow_item, file_path, mode, connections=None): if not flow_item.name: flow_item.name = os.path.splitext(filename)[0] if file_extension not in ALLOWED_FILE_EXTENSIONS: - error = "Only {} files can be published as flows.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) + error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) # Construct the url with the defined mode url = "{0}?flowType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += '&{0}=true'.format(mode.lower()) + url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: - logger.info('Publishing {0} to server with chunking method (flow over 64MB)'.format(filename)) + logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) - xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, - connections) + xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info('Publishing {0} to server'.format(filename)) - with open(file_path, 'rb') as f: + logger.info("Publishing {0} to server".format(filename)) + with open(file_path, "rb") as f: file_contents = f.read() - xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, - filename, - file_contents, - connections) + xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, filename, file_contents, connections) # Send the publishing request to server try: @@ -190,30 +185,32 @@ def publish(self, flow_item, file_path, mode, connections=None): raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow server_response = self.post_request(url, xml_request, content_type) new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(filename, new_flow.id)) + logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow - @api(version='3.3') + @api(version="3.3") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='3.3') + @api(version="3.3") def update_permission(self, item, permission_item): import warnings - warnings.warn('Server.flows.update_permission is deprecated, ' - 'please use Server.flows.update_permissions instead.', - DeprecationWarning) + + warnings.warn( + "Server.flows.update_permission is deprecated, " "please use Server.flows.update_permissions instead.", + DeprecationWarning, + ) self._permissions.update(item, permission_item) - @api(version='3.3') + @api(version="3.3") def update_permissions(self, item, permission_item): self._permissions.update(item, permission_item) - @api(version='3.3') + @api(version="3.3") def delete_permission(self, item, capability_item): self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 6a9b81afd..4a09872cb 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -5,7 +5,7 @@ import logging -logger = logging.getLogger('tableau.endpoint.groups') +logger = logging.getLogger("tableau.endpoint.groups") class Groups(Endpoint): @@ -16,7 +16,7 @@ def baseurl(self): # Gets all groups @api(version="2.0") def get(self, req_options=None): - logger.info('Querying all groups on site') + logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -42,7 +42,7 @@ def _get_users_for_group(self, group_item, req_options=None): server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info('Populated users for group (ID: {0})'.format(group_item.id)) + logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item # Deletes 1 group by id @@ -53,30 +53,33 @@ def delete(self, group_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, group_id) self.delete_request(url) - logger.info('Deleted single group (ID: {0})'.format(group_id)) + logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") def update(self, group_item, default_site_role=None, as_job=False): # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: import warnings - warnings.simplefilter('always', DeprecationWarning) - warnings.warn('Groups.update(...default_site_role=""...) is deprecated, ' - 'please set the minimum_site_role field of GroupItem', - DeprecationWarning) + + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + 'Groups.update(...default_site_role=""...) is deprecated, ' + "please set the minimum_site_role field of GroupItem", + DeprecationWarning, + ) group_item.minimum_site_role = default_site_role if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) - if as_job and (group_item.domain_name is None or group_item.domain_name == 'local'): + if as_job and (group_item.domain_name is None or group_item.domain_name == "local"): error = "Local groups cannot be updated asynchronously." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, group_item.id) update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) - logger.info('Updated group item (ID: {0})'.format(group_item.id)) + logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -97,7 +100,7 @@ def create_AD_group(self, group_item, asJob=False): url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) server_response = self.post_request(url, create_req) - if (asJob): + if asJob: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -113,7 +116,7 @@ def remove_user(self, group_item, user_id): raise ValueError(error) url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) self.delete_request(url) - logger.info('Removed user (id: {0}) from group (ID: {1})'.format(user_id, group_item.id)) + logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) # Adds 1 user to 1 group @api(version="2.0") @@ -128,5 +131,5 @@ def add_user(self, group_item, user_id): add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info('Added user (id: {0}) to group (ID: {1})'.format(user_id, group_item.id)) + logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index d8bbe39c7..6079ca788 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -10,7 +10,7 @@ # In case we are in python 3 the string check is different basestring = str -logger = logging.getLogger('tableau.endpoint.jobs') +logger = logging.getLogger("tableau.endpoint.jobs") class Jobs(Endpoint): @@ -18,31 +18,32 @@ class Jobs(Endpoint): def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) - @api(version='2.6') + @api(version="2.6") def get(self, job_id=None, req_options=None): # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, basestring): import warnings + warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") return self.get_by_id(job_id) if isinstance(job_id, RequestOptionsBase): req_options = job_id - self.parent_srv.assert_at_least_version('3.1') + self.parent_srv.assert_at_least_version("3.1") server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) return jobs, pagination_item - @api(version='3.1') + @api(version="3.1") def cancel(self, job_id): - id_ = getattr(job_id, 'id', job_id) - url = '{0}/{1}'.format(self.baseurl, id_) + id_ = getattr(job_id, "id", job_id) + url = "{0}/{1}".format(self.baseurl, id_) return self.put_request(url) - @api(version='2.6') + @api(version="2.6") def get_by_id(self, job_id): - logger.info('Query for information about job ' + job_id) + logger.info("Query for information about job " + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index ac111d6ef..368a92a97 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ import json -logger = logging.getLogger('tableau.endpoint.metadata') +logger = logging.getLogger("tableau.endpoint.metadata") def is_valid_paged_query(parsed_query): @@ -12,9 +12,11 @@ def is_valid_paged_query(parsed_query): Also check that we are asking for the pageInfo object, so we get the endCursor. There is no way to do this relilably without writing a GraphQL parser, so simply check that that the string contains 'hasNextPage' and 'endCursor'""" - return all(k in parsed_query['variables'] for k in ('first', 'afterToken')) and \ - 'hasNextPage' in parsed_query['query'] and \ - 'endCursor' in parsed_query['query'] + return ( + all(k in parsed_query["variables"] for k in ("first", "afterToken")) + and "hasNextPage" in parsed_query["query"] + and "endCursor" in parsed_query["query"] + ) def extract_values(obj, key): @@ -40,8 +42,8 @@ def extract(obj, arr, key): def get_page_info(result): - next_page = extract_values(result, 'hasNextPage').pop() - cursor = extract_values(result, 'endCursor').pop() + next_page = extract_values(result, "hasNextPage").pop() + cursor = extract_values(result, "endCursor").pop() return next_page, cursor @@ -56,20 +58,20 @@ def control_baseurl(self): @api("3.5") def query(self, query, variables=None, abort_on_error=False): - logger.info('Querying Metadata API') + logger.info("Querying Metadata API") url = self.baseurl try: - graphql_query = json.dumps({'query': query, 'variables': variables}) + graphql_query = json.dumps({"query": query, "variables": variables}) except Exception as e: - raise InvalidGraphQLQuery('Must provide a string') + raise InvalidGraphQLQuery("Must provide a string") # Setting content type because post_reuqest defaults to text/xml - server_response = self.post_request(url, graphql_query, content_type='text/json') + server_response = self.post_request(url, graphql_query, content_type="text/json") results = server_response.json() - if abort_on_error and results.get('errors', None): - raise GraphQLError(results['errors']) + if abort_on_error and results.get("errors", None): + raise GraphQLError(results["errors"]) return results @@ -87,32 +89,34 @@ def eventing_status(self): @api("3.5") def paginated_query(self, query, variables=None, abort_on_error=False): - logger.info('Querying Metadata API using a Paged Query') + logger.info("Querying Metadata API using a Paged Query") url = self.baseurl if variables is None: # default paramaters - variables = {'first': 100, 'afterToken': None} - elif (('first' in variables) and ('afterToken' not in variables)): + variables = {"first": 100, "afterToken": None} + elif ("first" in variables) and ("afterToken" not in variables): # they passed a page size but not a token, probably because they're starting at `null` token - variables.update({'afterToken': None}) + variables.update({"afterToken": None}) - graphql_query = json.dumps({'query': query, 'variables': variables}) + graphql_query = json.dumps({"query": query, "variables": variables}) parsed_query = json.loads(graphql_query) if not is_valid_paged_query(parsed_query): - raise InvalidGraphQLQuery('Paged queries must have a `$first` and `$afterToken` variables as well as ' - 'a pageInfo object with `endCursor` and `hasNextPage`') + raise InvalidGraphQLQuery( + "Paged queries must have a `$first` and `$afterToken` variables as well as " + "a pageInfo object with `endCursor` and `hasNextPage`" + ) - results_dict = {'pages': []} - paginated_results = results_dict['pages'] + results_dict = {"pages": []} + paginated_results = results_dict["pages"] # get first page - server_response = self.post_request(url, graphql_query, content_type='text/json') + server_response = self.post_request(url, graphql_query, content_type="text/json") results = server_response.json() - if abort_on_error and results.get('errors', None): - raise GraphQLError(results['errors']) + if abort_on_error and results.get("errors", None): + raise GraphQLError(results["errors"]) paginated_results.append(results) @@ -121,18 +125,18 @@ def paginated_query(self, query, variables=None, abort_on_error=False): while has_another_page: # Update the page - variables.update({'afterToken': cursor}) + variables.update({"afterToken": cursor}) # make the call logger.debug("Calling Token: " + cursor) - graphql_query = json.dumps({'query': query, 'variables': variables}) - server_response = self.post_request(url, graphql_query, content_type='text/json') + graphql_query = json.dumps({"query": query, "variables": variables}) + server_response = self.post_request(url, graphql_query, content_type="text/json") results = server_response.json() # verify response - if abort_on_error and results.get('errors', None): - raise GraphQLError(results['errors']) + if abort_on_error and results.get("errors", None): + raise GraphQLError(results["errors"]) # save results and repeat paginated_results.append(results) has_another_page, cursor = get_page_info(results) - logger.info('Sucessfully got all results for paged query') + logger.info("Sucessfully got all results for paged query") return results_dict diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 585fd0052..0992f5ca9 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -10,7 +10,7 @@ class _PermissionsEndpoint(Endpoint): - """ Adds permission model to another endpoint + """Adds permission model to another endpoint Tableau permissions model is identical between objects but they are nested under the parent object endpoint (i.e. permissions for workbooks are under @@ -27,12 +27,11 @@ def __init__(self, parent_srv, owner_baseurl): self.owner_baseurl = owner_baseurl def update(self, resource, permissions): - url = '{0}/{1}/permissions'.format(self.owner_baseurl(), resource.id) + url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) - permissions = PermissionsRule.from_response(response.content, - self.parent_srv.namespace) - logger.info('Updated permissions for resource {0}'.format(resource.id)) + permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) + logger.info("Updated permissions for resource {0}".format(resource.id)) return permissions @@ -46,23 +45,17 @@ def delete(self, resource, rules): for rule in rules: for capability, mode in rule.capabilities.items(): " /permissions/groups/group-id/capability-name/capability-mode" - url = '{0}/{1}/permissions/{2}/{3}/{4}/{5}'.format( - self.owner_baseurl(), - resource.id, - rule.grantee.tag_name + 's', - rule.grantee.id, - capability, - mode) + url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", rule.grantee.id, capability, mode + ) - logger.debug('Removing {0} permission for capabilty {1}'.format( - mode, capability)) + logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) self.delete_request(url) - logger.info('Deleted permission for {0} {1} item {2}'.format( - rule.grantee.tag_name, - rule.grantee.id, - resource.id)) + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) def populate(self, item): if not item.id: @@ -73,12 +66,11 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info('Populated permissions for item (ID: {0})'.format(item.id)) + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_permissions(self, item, req_options=None): url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) - permissions = PermissionsRule.from_response(server_response.content, - self.parent_srv.namespace) + permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 170425eab..72286e570 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -7,7 +7,7 @@ import logging -logger = logging.getLogger('tableau.endpoint.projects') +logger = logging.getLogger("tableau.endpoint.projects") class Projects(Endpoint): @@ -23,7 +23,7 @@ def baseurl(self): @api(version="2.0") def get(self, req_options=None): - logger.info('Querying all projects on site') + logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -37,7 +37,7 @@ def delete(self, project_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, project_id) self.delete_request(url) - logger.info('Deleted single project (ID: {0})'.format(project_id)) + logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") def update(self, project_item): @@ -48,7 +48,7 @@ def update(self, project_item): url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req) - logger.info('Updated project item (ID: {0})'.format(project_item.id)) + logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -58,61 +58,64 @@ def create(self, project_item): create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Created new project (ID: {0})'.format(new_project.id)) + logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project - @api(version='2.0') + @api(version="2.0") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='2.0') + @api(version="2.0") def update_permission(self, item, rules): import warnings - warnings.warn('Server.projects.update_permission is deprecated, ' - 'please use Server.projects.update_permissions instead.', - DeprecationWarning) + + warnings.warn( + "Server.projects.update_permission is deprecated, " + "please use Server.projects.update_permissions instead.", + DeprecationWarning, + ) return self._permissions.update(item, rules) - @api(version='2.0') + @api(version="2.0") def update_permissions(self, item, rules): return self._permissions.update(item, rules) - @api(version='2.0') + @api(version="2.0") def delete_permission(self, item, rules): self._permissions.delete(item, rules) - @api(version='2.1') + @api(version="2.1") def populate_workbook_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook) - @api(version='2.1') + @api(version="2.1") def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource) - @api(version='3.4') + @api(version="3.4") def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) - @api(version='2.1') + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) - @api(version='2.1') + @api(version="2.1") def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) - @api(version='3.4') + @api(version="3.4") def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) - @api(version='2.1') + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) - @api(version='2.1') + @api(version="2.1") def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource) - @api(version='3.4') + @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index ccee9fa10..a38c66ebe 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -6,7 +6,7 @@ import copy import urllib.parse -logger = logging.getLogger('tableau.endpoint.resource_tagger') +logger = logging.getLogger("tableau.endpoint.resource_tagger") class _ResourceTagger(Endpoint): @@ -47,4 +47,4 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info('Updated tags to {0}'.format(resource_item.tags)) + logger.info("Updated tags to {0}".format(resource_item.tags)) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 3fd164b49..3a5e665fa 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -5,9 +5,9 @@ import copy from collections import namedtuple -logger = logging.getLogger('tableau.endpoint.schedules') +logger = logging.getLogger("tableau.endpoint.schedules") # Oh to have a first class Result concept in Python... -AddResponse = namedtuple('AddResponse', ('result', 'error', 'warnings', 'task_created')) +AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) @@ -65,8 +65,7 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") - def add_to_schedule(self, schedule_id, workbook=None, datasource=None, - task_type=TaskItem.Type.ExtractRefresh): + def add_to_schedule(self, schedule_id, workbook=None, datasource=None, task_type=TaskItem.Type.ExtractRefresh): def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) @@ -74,7 +73,8 @@ def add_to(resource, type_, req_factory): response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response( - response, self.parent_srv.namespace) + response, self.parent_srv.namespace + ) if task_created: logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 0a6b9ec89..98d996b52 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -3,7 +3,7 @@ from ...models import ServerInfoItem import logging -logger = logging.getLogger('tableau.endpoint.server_info') +logger = logging.getLogger("tableau.endpoint.server_info") class ServerInfo(Endpoint): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index c57cb3d4f..9446a01a8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -5,7 +5,7 @@ import copy import logging -logger = logging.getLogger('tableau.endpoint.sites') +logger = logging.getLogger("tableau.endpoint.sites") class Sites(Endpoint): @@ -16,7 +16,7 @@ def baseurl(self): # Gets all sites @api(version="2.0") def get(self, req_options=None): - logger.info('Querying all sites on site') + logger.info("Querying all sites on site") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -29,7 +29,7 @@ def get_by_id(self, site_id): if not site_id: error = "Site ID undefined." raise ValueError(error) - logger.info('Querying single site (ID: {0})'.format(site_id)) + logger.info("Querying single site (ID: {0})".format(site_id)) url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -40,7 +40,7 @@ def get_by_name(self, site_name): if not site_name: error = "Site Name undefined." raise ValueError(error) - logger.info('Querying single site (Name: {0})'.format(site_name)) + logger.info("Querying single site (Name: {0})".format(site_name)) url = "{0}/{1}?key=name".format(self.baseurl, site_name) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,7 +51,7 @@ def get_by_content_url(self, content_url): if content_url is None: error = "Content URL undefined." raise ValueError(error) - logger.info('Querying single site (Content URL: {0})'.format(content_url)) + logger.info("Querying single site (Content URL: {0})".format(content_url)) url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -64,13 +64,13 @@ def update(self, site_item): raise MissingRequiredFieldError(error) if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: - error = 'You cannot set admin_mode to ContentOnly and also set a user quota' + error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_item.id) update_req = RequestFactory.Site.update_req(site_item) server_response = self.put_request(url, update_req) - logger.info('Updated site item (ID: {0})'.format(site_item.id)) + logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -85,23 +85,23 @@ def delete(self, site_id): # If we deleted the site we are logged into # then we are automatically logged out if site_id == self.parent_srv.site_id: - logger.info('Deleting current site and clearing auth tokens') + logger.info("Deleting current site and clearing auth tokens") self.parent_srv._clear_auth() - logger.info('Deleted single site (ID: {0}) and signed out'.format(site_id)) + logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # Create new site @api(version="2.0") def create(self, site_item): if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: - error = 'You cannot set admin_mode to ContentOnly and also set a user quota' + error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) url = self.baseurl create_req = RequestFactory.Site.create_req(site_item) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Created new site (ID: {0})'.format(new_site.id)) + logger.info("Created new site (ID: {0})".format(new_site.id)) return new_site @api(version="3.5") diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 120a2a17b..1a66e8ac5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -4,18 +4,17 @@ import logging -logger = logging.getLogger('tableau.endpoint.subscriptions') +logger = logging.getLogger("tableau.endpoint.subscriptions") class Subscriptions(Endpoint): @property def baseurl(self): - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, - self.parent_srv.site_id) + return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) - @api(version='2.3') + @api(version="2.3") def get(self, req_options=None): - logger.info('Querying all subscriptions for the site') + logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -23,7 +22,7 @@ def get(self, req_options=None): all_subscriptions = SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace) return all_subscriptions, pagination_item - @api(version='2.3') + @api(version="2.3") def get_by_id(self, subscription_id): if not subscription_id: error = "No Subscription ID provided" @@ -33,7 +32,7 @@ def get_by_id(self, subscription_id): server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - @api(version='2.3') + @api(version="2.3") def create(self, subscription_item): if not subscription_item: error = "No Susbcription provided" @@ -44,16 +43,16 @@ def create(self, subscription_item): server_response = self.post_request(url, create_req) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - @api(version='2.3') + @api(version="2.3") def delete(self, subscription_id): if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, subscription_id) self.delete_request(url) - logger.info('Deleted subscription (ID: {0})'.format(subscription_id)) + logger.info("Deleted subscription (ID: {0})".format(subscription_id)) - @api(version='2.3') + @api(version="2.3") def update(self, subscription_item): if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." @@ -61,5 +60,5 @@ def update(self, subscription_item): url = "{0}/{1}".format(self.baseurl, subscription_item.id) update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info('Updated subscription item (ID: {0})'.format(subscription_item.id)) + logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 3a5c2f3f4..e35535d19 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -7,7 +7,7 @@ import logging -logger = logging.getLogger('tableau.endpoint.tables') +logger = logging.getLogger("tableau.endpoint.tables") class Tables(Endpoint): @@ -22,7 +22,7 @@ def baseurl(self): @api(version="3.5") def get(self, req_options=None): - logger.info('Querying all tables on site') + logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -35,7 +35,7 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info('Querying single table (ID: {0})'.format(table_id)) + logger.info("Querying single table (ID: {0})".format(table_id)) url = "{0}/{1}".format(self.baseurl, table_id) server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -47,7 +47,7 @@ def delete(self, table_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, table_id) self.delete_request(url) - logger.info('Deleted single table (ID: {0})'.format(table_id)) + logger.info("Deleted single table (ID: {0})".format(table_id)) @api(version="3.5") def update(self, table_item): @@ -58,7 +58,7 @@ def update(self, table_item): url = "{0}/{1}".format(self.baseurl, table_item.id) update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info('Updated table item (ID: {0})'.format(table_item.id)) + logger.info("Updated table item (ID: {0})".format(table_item.id)) updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -73,13 +73,12 @@ def column_fetcher(): return Pager(lambda options: self._get_columns_for_table(table_item, options), req_options) table_item._set_columns(column_fetcher) - logger.info('Populated columns for table (ID: {0}'.format(table_item.id)) + logger.info("Populated columns for table (ID: {0}".format(table_item.id)) def _get_columns_for_table(self, table_item, req_options=None): url = "{0}/{1}/columns".format(self.baseurl, table_item.id) server_response = self.get_request(url, req_options) - columns = ColumnItem.from_response(server_response.content, - self.parent_srv.namespace) + columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return columns, pagination_item @@ -90,26 +89,27 @@ def update_column(self, table_item, column_item): server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Updated table item (ID: {0} & column item {1}'.format(table_item.id, - column_item.id)) + logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) return column - @api(version='3.5') + @api(version="3.5") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='3.5') + @api(version="3.5") def update_permission(self, item, rules): import warnings - warnings.warn('Server.tables.update_permission is deprecated, ' - 'please use Server.tables.update_permissions instead.', - DeprecationWarning) + + warnings.warn( + "Server.tables.update_permission is deprecated, " "please use Server.tables.update_permissions instead.", + DeprecationWarning, + ) return self._permissions.update(item, rules) - @api(version='3.5') + @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) - @api(version='3.5') + @api(version="3.5") def delete_permission(self, item, rules): return self._permissions.delete(item, rules) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a3e5e7b34..abc249721 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -4,61 +4,57 @@ import logging -logger = logging.getLogger('tableau.endpoint.tasks') +logger = logging.getLogger("tableau.endpoint.tasks") class Tasks(Endpoint): @property def baseurl(self): - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, - self.parent_srv.site_id) + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) def __normalize_task_type(self, task_type): """ - The word for extract refresh used in API URL is "extractRefreshes". - It is different than the tag "extractRefresh" used in the request body. + The word for extract refresh used in API URL is "extractRefreshes". + It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return '{}es'.format(task_type) + return "{}es".format(task_type) else: return task_type - @api(version='2.6') + @api(version="2.6") def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8") - logger.info('Querying all {} tasks for the site'.format(task_type)) + logger.info("Querying all {} tasks for the site".format(task_type)) url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, - self.parent_srv.namespace) - all_tasks = TaskItem.from_response(server_response.content, - self.parent_srv.namespace, - task_type) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_tasks = TaskItem.from_response(server_response.content, self.parent_srv.namespace, task_type) return all_tasks, pagination_item - @api(version='2.6') + @api(version="2.6") def get_by_id(self, task_id): if not task_id: error = "No Task ID provided" raise ValueError(error) logger.info("Querying a single task by id ({})".format(task_id)) - url = "{}/{}/{}".format(self.baseurl, - self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) + url = "{}/{}/{}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] - @api(version='2.6') + @api(version="2.6") def run(self, task_item): if not task_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format(self.baseurl, - self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id) + url = "{0}/{1}/{2}/runNow".format( + self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id + ) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) return server_response.content @@ -72,7 +68,6 @@ def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, - self.__normalize_task_type(task_type), task_id) + url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) - logger.info('Deleted single task (ID: {0})'.format(task_id)) + logger.info("Deleted single task (ID: {0})".format(task_id)) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 17e12a8b1..3318e6bb3 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -6,7 +6,7 @@ import copy import logging -logger = logging.getLogger('tableau.endpoint.users') +logger = logging.getLogger("tableau.endpoint.users") class Users(QuerysetEndpoint): @@ -17,7 +17,7 @@ def baseurl(self): # Gets all users @api(version="2.0") def get(self, req_options=None): - logger.info('Querying all users on site') + logger.info("Querying all users on site") if req_options is None: req_options = RequestOptions() @@ -35,7 +35,7 @@ def get_by_id(self, user_id): if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info('Querying single user (ID: {0})'.format(user_id)) + logger.info("Querying single user (ID: {0})".format(user_id)) url = "{0}/{1}".format(self.baseurl, user_id) server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() @@ -50,7 +50,7 @@ def update(self, user_item, password=None): url = "{0}/{1}".format(self.baseurl, user_item.id) update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info('Updated user item (ID: {0})'.format(user_item.id)) + logger.info("Updated user item (ID: {0})".format(user_item.id)) updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -62,7 +62,7 @@ def remove(self, user_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, user_id) self.delete_request(url) - logger.info('Removed single user (ID: {0})'.format(user_id)) + logger.info("Removed single user (ID: {0})".format(user_id)) # Add new user to site @api(version="2.0") @@ -71,7 +71,7 @@ def add(self, user_item): add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info('Added new user (ID: {0})'.format(new_user.id)) + logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user # Get workbooks for user @@ -89,7 +89,7 @@ def wb_pager(): def _get_wbs_for_user(self, user_item, req_options=None): url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info('Populated workbooks for user (ID: {0})'.format(user_item.id)) + logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item @@ -112,7 +112,7 @@ def groups_for_user_pager(): def _get_groups_for_user(self, user_item, req_options=None): url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info('Populated groups for user (ID: {0})'.format(user_item.id)) + logger.info("Populated groups for user (ID: {0})".format(user_item.id)) group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 8c848c295..a00e7f145 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from contextlib import closing import logging -logger = logging.getLogger('tableau.endpoint.views') +logger = logging.getLogger("tableau.endpoint.views") class Views(QuerysetEndpoint): @@ -27,7 +27,7 @@ def baseurl(self): @api(version="2.2") def get(self, req_options=None, usage=False): - logger.info('Querying all views on site') + logger.info("Querying all views on site") url = self.baseurl if usage: url += "?includeUsageStatistics=true" @@ -41,7 +41,7 @@ def get_by_id(self, view_id): if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info('Querying single view (ID: {0})'.format(view_id)) + logger.info("Querying single view (ID: {0})".format(view_id)) url = "{0}/{1}".format(self.baseurl, view_id) server_response = self.get_request(url) return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,12 +56,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info('Populated preview image for view (ID: {0})'.format(view_item.id)) + logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) def _get_preview_for_view(self, view_item): - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, - view_item.workbook_id, - view_item.id) + url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @@ -121,15 +119,15 @@ def _get_view_csv(self, view_item, req_options): csv = server_response.iter_content(1024) return csv - @api(version='3.2') + @api(version="3.2") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='3.2') + @api(version="3.2") def update_permissions(self, resource, rules): return self._permissions.update(resource, rules) - @api(version='3.2') + @api(version="3.2") def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index fe108a27d..6f5135ac1 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ import logging -logger = logging.getLogger('tableau.endpoint.webhooks') +logger = logging.getLogger("tableau.endpoint.webhooks") class Webhooks(Endpoint): @@ -17,7 +17,7 @@ def baseurl(self): @api(version="3.6") def get(self, req_options=None): - logger.info('Querying all Webhooks on site') + 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) @@ -29,7 +29,7 @@ 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)) + 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] @@ -41,7 +41,7 @@ def delete(self, webhook_id): 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)) + logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") def create(self, webhook_item): @@ -50,7 +50,7 @@ def create(self, 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)) + logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) return new_webhook @api(version="3.6") @@ -60,5 +60,5 @@ def test(self, webhook_id): raise ValueError(error) url = "{0}/{1}/test".format(self.baseurl, webhook_id) testOutcome = self.get_request(url) - logger.info('Testing webhook (ID: {0} returned {1})'.format(webhook_id, testOutcome)) + logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e40d9e1dd..aa72979dd 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -14,11 +14,11 @@ from contextlib import closing # The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB +FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB -ALLOWED_FILE_EXTENSIONS = ['twb', 'twbx'] +ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger('tableau.endpoint.workbooks') +logger = logging.getLogger("tableau.endpoint.workbooks") class Workbooks(QuerysetEndpoint): @@ -34,13 +34,11 @@ def baseurl(self): # Get all workbooks on site @api(version="2.0") def get(self, req_options=None): - logger.info('Querying all workbooks on site') + logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response( - server_response.content, self.parent_srv.namespace) - all_workbook_items = WorkbookItem.from_response( - server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_workbook_items = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) return all_workbook_items, pagination_item # Get 1 workbook @@ -49,14 +47,14 @@ def get_by_id(self, workbook_id): if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info('Querying single workbook (ID: {0})'.format(workbook_id)) + logger.info("Querying single workbook (ID: {0})".format(workbook_id)) url = "{0}/{1}".format(self.baseurl, workbook_id) server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_id): - id_ = getattr(workbook_id, 'id', workbook_id) + id_ = getattr(workbook_id, "id", workbook_id) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -64,9 +62,9 @@ def refresh(self, workbook_id): return new_job # create one or more extracts on 1 workbook, optionally encrypted - @api(version='3.5') + @api(version="3.5") def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): - id_ = getattr(workbook_item, 'id', workbook_item) + id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) @@ -75,9 +73,9 @@ def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasour return new_job # delete all the extracts on 1 workbook - @api(version='3.5') + @api(version="3.5") def delete_extract(self, workbook_item): - id_ = getattr(workbook_item, 'id', workbook_item) + id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -90,7 +88,7 @@ def delete(self, workbook_id): raise ValueError(error) url = "{0}/{1}".format(self.baseurl, workbook_id) self.delete_request(url) - logger.info('Deleted single workbook (ID: {0})'.format(workbook_id)) + logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) # Update workbook @api(version="2.0") @@ -105,14 +103,15 @@ def update(self, workbook_item): url = "{0}/{1}".format(self.baseurl, workbook_item.id) update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info('Updated workbook item (ID: {0})'.format(workbook_item.id)) + logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) @api(version="2.3") def update_conn(self, *args, **kwargs): import warnings - warnings.warn('update_conn is deprecated, please use update_connection instead') + + warnings.warn("update_conn is deprecated, please use update_connection instead") return self.update_connection(*args, **kwargs) # Update workbook_connection @@ -123,14 +122,15 @@ def update_connection(self, workbook_item, connection_item): server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Updated workbook item (ID: {0} & connection item {1})'.format(workbook_item.id, - connection_item.id)) + logger.info( + "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) + ) return connection # Download workbook contents with option of passing in filepath @api(version="2.0") - @parameter_added_in(no_extract='2.5') - @parameter_added_in(include_extract='2.5') + @parameter_added_in(no_extract="2.5") + @parameter_added_in(include_extract="2.5") def download(self, workbook_id, filepath=None, include_extract=True, no_extract=None): if not workbook_id: error = "Workbook ID undefined." @@ -139,22 +139,23 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= if no_extract is False or no_extract is True: import warnings - warnings.warn('no_extract is deprecated, use include_extract instead.', DeprecationWarning) + + warnings.warn("no_extract is deprecated, use include_extract instead.", DeprecationWarning) include_extract = not no_extract if not include_extract: url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers['Content-Disposition']) - filename = to_filename(os.path.basename(params['filename'])) + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) - with open(download_path, 'wb') as f: + with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB f.write(chunk) - logger.info('Downloaded workbook to {0} (ID: {1})'.format(download_path, workbook_id)) + logger.info("Downloaded workbook to {0} (ID: {1})".format(download_path, workbook_id)) return os.path.abspath(download_path) # Get all views of workbook @@ -168,16 +169,14 @@ def view_fetcher(): return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info('Populated views for workbook (ID: {0})'.format(workbook_item.id)) + logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) def _get_views_for_workbook(self, workbook_item, usage): url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) - views = ViewItem.from_response(server_response.content, - self.parent_srv.namespace, - workbook_id=workbook_item.id) + views = ViewItem.from_response(server_response.content, self.parent_srv.namespace, workbook_id=workbook_item.id) return views # Get all connections of workbook @@ -191,7 +190,7 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info('Populated connections for workbook (ID: {0})'.format(workbook_item.id)) + logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections(self, workbook_item, req_options=None): url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) @@ -229,7 +228,7 @@ def image_fetcher(): return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info('Populated preview image for workbook (ID: {0})'.format(workbook_item.id)) + logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_preview_image(self, workbook_item): url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) @@ -237,32 +236,38 @@ def _get_wb_preview_image(self, workbook_item): preview_image = server_response.content return preview_image - @api(version='2.0') + @api(version="2.0") def populate_permissions(self, item): self._permissions.populate(item) - @api(version='2.0') + @api(version="2.0") def update_permissions(self, resource, rules): return self._permissions.update(resource, rules) - @api(version='2.0') + @api(version="2.0") def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") - @parameter_added_in(as_job='3.0') - @parameter_added_in(connections='2.8') + @parameter_added_in(as_job="3.0") + @parameter_added_in(connections="2.8") def publish( - self, workbook_item, file, mode, - connection_credentials=None, connections=None, as_job=False, - hidden_views=None, skip_connection_check=False + self, + workbook_item, + file, + mode, + connection_credentials=None, + connections=None, + as_job=False, + hidden_views=None, + skip_connection_check=False, ): if connection_credentials is not None: import warnings - warnings.warn("connection_credentials is being deprecated. Use connections instead", - DeprecationWarning) + + warnings.warn("connection_credentials is being deprecated. Use connections instead", DeprecationWarning) try: # Expect file to be a filepath @@ -278,7 +283,7 @@ def publish( if not workbook_item.name: workbook_item.name = os.path.splitext(filename)[0] if file_extension not in ALLOWED_FILE_EXTENSIONS: - error = "Only {} files can be published as workbooks.".format(', '.join(ALLOWED_FILE_EXTENSIONS)) + error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) except TypeError: @@ -287,12 +292,12 @@ def publish( file_type = get_file_type(file) - if file_type == 'zip': - file_extension = 'twbx' - elif file_type == 'xml': - file_extension = 'twb' + if file_type == "zip": + file_extension = "twbx" + elif file_type == "xml": + file_extension = "twb" else: - error = 'Unsupported file type {}!'.format(file_type) + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) if not workbook_item.name: @@ -304,51 +309,52 @@ def publish( filename = "{}.{}".format(workbook_item.name, file_extension) if not hasattr(self.parent_srv.PublishMode, mode): - error = 'Invalid mode defined.' + error = "Invalid mode defined." raise ValueError(error) # Construct the url with the defined mode url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: - url += '&{0}=true'.format(mode.lower()) + url += "&{0}=true".format(mode.lower()) elif mode == self.parent_srv.PublishMode.Append: - error = 'Workbooks cannot be appended.' + error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += '&{0}=true'.format('asJob') + url += "&{0}=true".format("asJob") if skip_connection_check: - url += '&{0}=true'.format('skipConnectionCheck') + url += "&{0}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(workbook_item.name)) + logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) conn_creds = connection_credentials - xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item, - connection_credentials=conn_creds, - connections=connections, - hidden_views=hidden_views) + xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( + workbook_item, connection_credentials=conn_creds, connections=connections, hidden_views=hidden_views + ) else: - logger.info('Publishing {0} to server'.format(filename)) + logger.info("Publishing {0} to server".format(filename)) try: - with open(file, 'rb') as f: + with open(file, "rb") as f: file_contents = f.read() except TypeError: file_contents = file.read() conn_creds = connection_credentials - xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item, - filename, - file_contents, - connection_credentials=conn_creds, - connections=connections, - hidden_views=hidden_views) - logger.debug('Request xml: {0} '.format(xml_request[:1000])) + xml_request, content_type = RequestFactory.Workbook.publish_req( + workbook_item, + filename, + file_contents, + connection_credentials=conn_creds, + connections=connections, + hidden_views=hidden_views, + ) + logger.debug("Request xml: {0} ".format(xml_request[:1000])) # Send the publishing request to server try: @@ -360,9 +366,9 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (JOB_ID: {1}'.format(workbook_item.name, new_job.id)) + logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info('Published {0} (ID: {1})'.format(workbook_item.name, new_workbook.id)) + logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index aa42a6439..8802321fd 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -11,8 +11,8 @@ def __init__(self, field, operator, value): def __str__(self): value_string = str(self._value) if isinstance(self._value, list): - value_string = value_string.replace(' ', '').replace('\'', '') - return '{0}:{1}:{2}'.format(self.field, self.operator, value_string) + value_string = value_string.replace(" ", "").replace("'", "") + return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 0e2382fae..2de84b4d1 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -14,7 +14,7 @@ class Pager(object): def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, 'get'): + if hasattr(endpoint, "get"): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) self._endpoint = endpoint @@ -30,7 +30,7 @@ def __init__(self, endpoint, request_opts=None, **kwargs): # If we have options we could be starting on any page, backfill the count if self._options: - self._count = ((self._options.pagenumber - 1) * self._options.pagesize) + self._count = (self._options.pagenumber - 1) * self._options.pagesize else: self._count = 0 self._options = RequestOptions() diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index c8ba5e6c6..3dbb830fa 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -4,11 +4,10 @@ def to_camel_case(word): - return word.split('_')[0] + ''.join(x.capitalize() or '_' for x in word.split('_')[1:]) + return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) class QuerySet: - def __init__(self, model): self.model = model self.request_options = RequestOptions() diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e34220188..d2e921479 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -13,13 +13,13 @@ def _add_multipart(parts): multipart_part.make_multipart(content_type=content_type) mime_multipart_parts.append(multipart_part) xml_request, content_type = encode_multipart_formdata(mime_multipart_parts) - content_type = ''.join(('multipart/mixed',) + content_type.partition(';')[1:]) + content_type = "".join(("multipart/mixed",) + content_type.partition(";")[1:]) return xml_request, content_type def _tsrequest_wrapped(func): def wrapper(self, *args, **kwargs): - xml_request = ET.Element('tsRequest') + xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -27,182 +27,184 @@ def wrapper(self, *args, **kwargs): def _add_connections_element(connections_element, connection): - connection_element = ET.SubElement(connections_element, 'connection') - connection_element.attrib['serverAddress'] = connection.server_address + connection_element = ET.SubElement(connections_element, "connection") + connection_element.attrib["serverAddress"] = connection.server_address if connection.server_port: - connection_element.attrib['serverPort'] = connection.server_port + connection_element.attrib["serverPort"] = connection.server_port if connection.connection_credentials: connection_credentials = connection.connection_credentials _add_credentials_element(connection_element, connection_credentials) def _add_hiddenview_element(views_element, view_name): - view_element = ET.SubElement(views_element, 'view') - view_element.attrib['name'] = view_name - view_element.attrib['hidden'] = "true" + view_element = ET.SubElement(views_element, "view") + view_element.attrib["name"] = view_name + view_element.attrib["hidden"] = "true" def _add_credentials_element(parent_element, connection_credentials): - credentials_element = ET.SubElement(parent_element, 'connectionCredentials') - credentials_element.attrib['name'] = connection_credentials.name - credentials_element.attrib['password'] = connection_credentials.password - credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false' + credentials_element = ET.SubElement(parent_element, "connectionCredentials") + credentials_element.attrib["name"] = connection_credentials.name + credentials_element.attrib["password"] = connection_credentials.password + credentials_element.attrib["embed"] = "true" if connection_credentials.embed else "false" if connection_credentials.oauth: - credentials_element.attrib['oAuth'] = 'true' + credentials_element.attrib["oAuth"] = "true" class AuthRequest(object): def signin_req(self, auth_item): - xml_request = ET.Element('tsRequest') + xml_request = ET.Element("tsRequest") - credentials_element = ET.SubElement(xml_request, 'credentials') + credentials_element = ET.SubElement(xml_request, "credentials") for attribute_name, attribute_value in auth_item.credentials.items(): credentials_element.attrib[attribute_name] = attribute_value - site_element = ET.SubElement(credentials_element, 'site') - site_element.attrib['contentUrl'] = auth_item.site_id + site_element = ET.SubElement(credentials_element, "site") + site_element.attrib["contentUrl"] = auth_item.site_id if auth_item.user_id_to_impersonate: - user_element = ET.SubElement(credentials_element, 'user') - user_element.attrib['id'] = auth_item.user_id_to_impersonate + user_element = ET.SubElement(credentials_element, "user") + user_element.attrib["id"] = auth_item.user_id_to_impersonate return ET.tostring(xml_request) def switch_req(self, site_content_url): - xml_request = ET.Element('tsRequest') + xml_request = ET.Element("tsRequest") - site_element = ET.SubElement(xml_request, 'site') - site_element.attrib['contentUrl'] = site_content_url + site_element = ET.SubElement(xml_request, "site") + site_element.attrib["contentUrl"] = site_content_url return ET.tostring(xml_request) class ColumnRequest(object): def update_req(self, column_item): - xml_request = ET.Element('tsRequest') - column_element = ET.SubElement(xml_request, 'column') + xml_request = ET.Element("tsRequest") + column_element = ET.SubElement(xml_request, "column") if column_item.description: - column_element.attrib['description'] = str(column_item.description) + column_element.attrib["description"] = str(column_item.description) return ET.tostring(xml_request) class DataAlertRequest(object): def add_user_to_alert(self, alert_item, user_id): - xml_request = ET.Element('tsRequest') - user_element = ET.SubElement(xml_request, 'user') - user_element.attrib['id'] = user_id + xml_request = ET.Element("tsRequest") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["id"] = user_id return ET.tostring(xml_request) def update_req(self, alert_item): - xml_request = ET.Element('tsRequest') - dataAlert_element = ET.SubElement(xml_request, 'dataAlert') - dataAlert_element.attrib['subject'] = alert_item.subject - dataAlert_element.attrib['frequency'] = alert_item.frequency.lower() - dataAlert_element.attrib['public'] = alert_item.public + xml_request = ET.Element("tsRequest") + dataAlert_element = ET.SubElement(xml_request, "dataAlert") + dataAlert_element.attrib["subject"] = alert_item.subject + dataAlert_element.attrib["frequency"] = alert_item.frequency.lower() + dataAlert_element.attrib["public"] = alert_item.public - owner = ET.SubElement(dataAlert_element, 'owner') - owner.attrib['id'] = alert_item.owner_id + owner = ET.SubElement(dataAlert_element, "owner") + owner.attrib["id"] = alert_item.owner_id return ET.tostring(xml_request) class DatabaseRequest(object): def update_req(self, database_item): - xml_request = ET.Element('tsRequest') - database_element = ET.SubElement(xml_request, 'database') + xml_request = ET.Element("tsRequest") + database_element = ET.SubElement(xml_request, "database") if database_item.contact_id: - contact_element = ET.SubElement(database_element, 'contact') - contact_element.attrib['id'] = database_item.contact_id + contact_element = ET.SubElement(database_element, "contact") + contact_element.attrib["id"] = database_item.contact_id - database_element.attrib['isCertified'] = str(database_item.certified).lower() + database_element.attrib["isCertified"] = str(database_item.certified).lower() if database_item.certification_note: - database_element.attrib['certificationNote'] = str(database_item.certification_note) + database_element.attrib["certificationNote"] = str(database_item.certification_note) if database_item.description: - database_element.attrib['description'] = str(database_item.description) + database_element.attrib["description"] = str(database_item.description) return ET.tostring(xml_request) class DatasourceRequest(object): def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): - xml_request = ET.Element('tsRequest') - datasource_element = ET.SubElement(xml_request, 'datasource') - datasource_element.attrib['name'] = datasource_item.name + xml_request = ET.Element("tsRequest") + datasource_element = ET.SubElement(xml_request, "datasource") + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: - datasource_element.attrib['description'] = str(datasource_item.description) + datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: - datasource_element.attrib['useRemoteQueryAgent'] = str(datasource_item.use_remote_query_agent).lower() + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() if datasource_item.ask_data_enablement: - ask_data_element = ET.SubElement(datasource_element, 'askData') - ask_data_element.attrib['enablement'] = datasource_item.ask_data_enablement + ask_data_element = ET.SubElement(datasource_element, "askData") + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement - project_element = ET.SubElement(datasource_element, 'project') - project_element.attrib['id'] = datasource_item.project_id + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: - raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + raise RuntimeError("You cannot set both `connections` and `connection_credentials`") if connection_credentials is not None: _add_credentials_element(datasource_element, connection_credentials) if connections is not None: - connections_element = ET.SubElement(datasource_element, 'connections') + connections_element = ET.SubElement(datasource_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, datasource_item): - xml_request = ET.Element('tsRequest') - datasource_element = ET.SubElement(xml_request, 'datasource') + xml_request = ET.Element("tsRequest") + datasource_element = ET.SubElement(xml_request, "datasource") if datasource_item.ask_data_enablement: - ask_data_element = ET.SubElement(datasource_element, 'askData') - ask_data_element.attrib['enablement'] = datasource_item.ask_data_enablement + ask_data_element = ET.SubElement(datasource_element, "askData") + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement if datasource_item.project_id: - project_element = ET.SubElement(datasource_element, 'project') - project_element.attrib['id'] = datasource_item.project_id + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if datasource_item.owner_id: - owner_element = ET.SubElement(datasource_element, 'owner') - owner_element.attrib['id'] = datasource_item.owner_id + owner_element = ET.SubElement(datasource_element, "owner") + owner_element.attrib["id"] = datasource_item.owner_id - datasource_element.attrib['isCertified'] = str(datasource_item.certified).lower() + datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() if datasource_item.certification_note: - datasource_element.attrib['certificationNote'] = str(datasource_item.certification_note) + datasource_element.attrib["certificationNote"] = str(datasource_item.certification_note) if datasource_item.encrypt_extracts is not None: - datasource_element.attrib['encryptExtracts'] = str(datasource_item.encrypt_extracts).lower() + datasource_element.attrib["encryptExtracts"] = str(datasource_item.encrypt_extracts).lower() return ET.tostring(xml_request) def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None): xml_request = self._generate_xml(datasource_item, connection_credentials, connections) - parts = {'request_payload': ('', xml_request, 'text/xml'), - 'tableau_datasource': (filename, file_contents, 'application/octet-stream')} + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_datasource": (filename, file_contents, "application/octet-stream"), + } return _add_multipart(parts) def publish_req_chunked(self, datasource_item, connection_credentials=None, connections=None): xml_request = self._generate_xml(datasource_item, connection_credentials, connections) - parts = {'request_payload': ('', xml_request, 'text/xml')} + parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) class FavoriteRequest(object): def _add_to_req(self, id_, target_type, label): - ''' + """ - ''' - xml_request = ET.Element('tsRequest') - favorite_element = ET.SubElement(xml_request, 'favorite') + """ + xml_request = ET.Element("tsRequest") + favorite_element = ET.SubElement(xml_request, "favorite") target = ET.SubElement(favorite_element, target_type) - favorite_element.attrib['label'] = label - target.attrib['id'] = id_ + favorite_element.attrib["label"] = label + target.attrib["id"] = id_ return ET.tostring(xml_request) @@ -221,208 +223,212 @@ def add_workbook_req(self, id_, name): class FileuploadRequest(object): def chunk_req(self, chunk): - parts = {'request_payload': ('', '', 'text/xml'), - 'tableau_file': ('file', chunk, 'application/octet-stream')} + parts = {"request_payload": ("", "", "text/xml"), "tableau_file": ("file", chunk, "application/octet-stream")} return _add_multipart(parts) class FlowRequest(object): def _generate_xml(self, flow_item, connections=None): - xml_request = ET.Element('tsRequest') - flow_element = ET.SubElement(xml_request, 'flow') - flow_element.attrib['name'] = flow_item.name - project_element = ET.SubElement(flow_element, 'project') - project_element.attrib['id'] = flow_item.project_id + xml_request = ET.Element("tsRequest") + flow_element = ET.SubElement(xml_request, "flow") + flow_element.attrib["name"] = flow_item.name + project_element = ET.SubElement(flow_element, "project") + project_element.attrib["id"] = flow_item.project_id if connections is not None: - connections_element = ET.SubElement(flow_element, 'connections') + connections_element = ET.SubElement(flow_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) return ET.tostring(xml_request) def update_req(self, flow_item): - xml_request = ET.Element('tsRequest') - flow_element = ET.SubElement(xml_request, 'flow') + xml_request = ET.Element("tsRequest") + flow_element = ET.SubElement(xml_request, "flow") if flow_item.project_id: - project_element = ET.SubElement(flow_element, 'project') - project_element.attrib['id'] = flow_item.project_id + project_element = ET.SubElement(flow_element, "project") + project_element.attrib["id"] = flow_item.project_id if flow_item.owner_id: - owner_element = ET.SubElement(flow_element, 'owner') - owner_element.attrib['id'] = flow_item.owner_id + owner_element = ET.SubElement(flow_element, "owner") + owner_element.attrib["id"] = flow_item.owner_id return ET.tostring(xml_request) def publish_req(self, flow_item, filename, file_contents, connections=None): xml_request = self._generate_xml(flow_item, connections) - parts = {'request_payload': ('', xml_request, 'text/xml'), - 'tableau_flow': (filename, file_contents, 'application/octet-stream')} + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_flow": (filename, file_contents, "application/octet-stream"), + } return _add_multipart(parts) def publish_req_chunked(self, flow_item, connections=None): xml_request = self._generate_xml(flow_item, connections) - parts = {'request_payload': ('', xml_request, 'text/xml')} + parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) class GroupRequest(object): def add_user_req(self, user_id): - xml_request = ET.Element('tsRequest') - user_element = ET.SubElement(xml_request, 'user') - user_element.attrib['id'] = user_id + xml_request = ET.Element("tsRequest") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["id"] = user_id return ET.tostring(xml_request) def create_local_req(self, group_item): - xml_request = ET.Element('tsRequest') - group_element = ET.SubElement(xml_request, 'group') - group_element.attrib['name'] = group_item.name + xml_request = ET.Element("tsRequest") + group_element = ET.SubElement(xml_request, "group") + group_element.attrib["name"] = group_item.name if group_item.minimum_site_role is not None: - group_element.attrib['minimumSiteRole'] = group_item.minimum_site_role + group_element.attrib["minimumSiteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) def create_ad_req(self, group_item): - xml_request = ET.Element('tsRequest') - group_element = ET.SubElement(xml_request, 'group') - group_element.attrib['name'] = group_item.name - import_element = ET.SubElement(group_element, 'import') - import_element.attrib['source'] = "ActiveDirectory" + xml_request = ET.Element("tsRequest") + group_element = ET.SubElement(xml_request, "group") + group_element.attrib["name"] = group_item.name + import_element = ET.SubElement(group_element, "import") + import_element.attrib["source"] = "ActiveDirectory" if group_item.domain_name is None: error = "Group Domain undefined." raise ValueError(error) - import_element.attrib['domainName'] = group_item.domain_name + import_element.attrib["domainName"] = group_item.domain_name if group_item.license_mode is not None: - import_element.attrib['grantLicenseMode'] = group_item.license_mode + import_element.attrib["grantLicenseMode"] = group_item.license_mode if group_item.minimum_site_role is not None: - import_element.attrib['siteRole'] = group_item.minimum_site_role + import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) def update_req(self, group_item, default_site_role=None): # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: import warnings - warnings.simplefilter('always', DeprecationWarning) - warnings.warn('RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' - 'please set the minimum_site_role field of GroupItem', - DeprecationWarning) + + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + 'RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' + "please set the minimum_site_role field of GroupItem", + DeprecationWarning, + ) group_item.minimum_site_role = default_site_role - xml_request = ET.Element('tsRequest') - group_element = ET.SubElement(xml_request, 'group') - group_element.attrib['name'] = group_item.name - if group_item.domain_name is not None and group_item.domain_name != 'local': + xml_request = ET.Element("tsRequest") + group_element = ET.SubElement(xml_request, "group") + group_element.attrib["name"] = group_item.name + if group_item.domain_name is not None and group_item.domain_name != "local": # Import element is only accepted in the request for AD groups - import_element = ET.SubElement(group_element, 'import') - import_element.attrib['source'] = "ActiveDirectory" - import_element.attrib['domainName'] = group_item.domain_name - import_element.attrib['siteRole'] = group_item.minimum_site_role + import_element = ET.SubElement(group_element, "import") + import_element.attrib["source"] = "ActiveDirectory" + import_element.attrib["domainName"] = group_item.domain_name + import_element.attrib["siteRole"] = group_item.minimum_site_role if group_item.license_mode is not None: - import_element.attrib['grantLicenseMode'] = group_item.license_mode + import_element.attrib["grantLicenseMode"] = group_item.license_mode else: # Local group request does not accept an 'import' element if group_item.minimum_site_role is not None: - group_element.attrib['minimumSiteRole'] = group_item.minimum_site_role + group_element.attrib["minimumSiteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) class PermissionRequest(object): def add_req(self, rules): - xml_request = ET.Element('tsRequest') - permissions_element = ET.SubElement(xml_request, 'permissions') + xml_request = ET.Element("tsRequest") + permissions_element = ET.SubElement(xml_request, "permissions") for rule in rules: - grantee_capabilities_element = ET.SubElement(permissions_element, 'granteeCapabilities') + grantee_capabilities_element = ET.SubElement(permissions_element, "granteeCapabilities") grantee_element = ET.SubElement(grantee_capabilities_element, rule.grantee.tag_name) - grantee_element.attrib['id'] = rule.grantee.id + grantee_element.attrib["id"] = rule.grantee.id - capabilities_element = ET.SubElement(grantee_capabilities_element, 'capabilities') + capabilities_element = ET.SubElement(grantee_capabilities_element, "capabilities") self._add_all_capabilities(capabilities_element, rule.capabilities) return ET.tostring(xml_request) def _add_all_capabilities(self, capabilities_element, capabilities_map): for name, mode in capabilities_map.items(): - capability_element = ET.SubElement(capabilities_element, 'capability') - capability_element.attrib['name'] = name - capability_element.attrib['mode'] = mode + capability_element = ET.SubElement(capabilities_element, "capability") + capability_element.attrib["name"] = name + capability_element.attrib["mode"] = mode class ProjectRequest(object): def update_req(self, project_item): - xml_request = ET.Element('tsRequest') - project_element = ET.SubElement(xml_request, 'project') + xml_request = ET.Element("tsRequest") + project_element = ET.SubElement(xml_request, "project") if project_item.name: - project_element.attrib['name'] = project_item.name + project_element.attrib["name"] = project_item.name if project_item.description: - project_element.attrib['description'] = project_item.description + project_element.attrib["description"] = project_item.description if project_item.content_permissions: - project_element.attrib['contentPermissions'] = project_item.content_permissions + project_element.attrib["contentPermissions"] = project_item.content_permissions if project_item.parent_id is not None: - project_element.attrib['parentProjectId'] = project_item.parent_id + project_element.attrib["parentProjectId"] = project_item.parent_id return ET.tostring(xml_request) def create_req(self, project_item): - xml_request = ET.Element('tsRequest') - project_element = ET.SubElement(xml_request, 'project') - project_element.attrib['name'] = project_item.name + xml_request = ET.Element("tsRequest") + project_element = ET.SubElement(xml_request, "project") + project_element.attrib["name"] = project_item.name if project_item.description: - project_element.attrib['description'] = project_item.description + project_element.attrib["description"] = project_item.description if project_item.content_permissions: - project_element.attrib['contentPermissions'] = project_item.content_permissions + project_element.attrib["contentPermissions"] = project_item.content_permissions if project_item.parent_id: - project_element.attrib['parentProjectId'] = project_item.parent_id + project_element.attrib["parentProjectId"] = project_item.parent_id return ET.tostring(xml_request) class ScheduleRequest(object): def create_req(self, schedule_item): - xml_request = ET.Element('tsRequest') - schedule_element = ET.SubElement(xml_request, 'schedule') - schedule_element.attrib['name'] = schedule_item.name - schedule_element.attrib['priority'] = str(schedule_item.priority) - schedule_element.attrib['type'] = schedule_item.schedule_type - schedule_element.attrib['executionOrder'] = schedule_item.execution_order + xml_request = ET.Element("tsRequest") + schedule_element = ET.SubElement(xml_request, "schedule") + schedule_element.attrib["name"] = schedule_item.name + schedule_element.attrib["priority"] = str(schedule_item.priority) + schedule_element.attrib["type"] = schedule_item.schedule_type + schedule_element.attrib["executionOrder"] = schedule_item.execution_order interval_item = schedule_item.interval_item - schedule_element.attrib['frequency'] = interval_item._frequency - frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') - frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: - frequency_element.attrib['end'] = str(interval_item.end_time) - if hasattr(interval_item, 'interval') and interval_item.interval: - intervals_element = ET.SubElement(frequency_element, 'intervals') + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") for interval in interval_item._interval_type_pairs(): expression, value = interval - single_interval_element = ET.SubElement(intervals_element, 'interval') + single_interval_element = ET.SubElement(intervals_element, "interval") single_interval_element.attrib[expression] = value return ET.tostring(xml_request) def update_req(self, schedule_item): - xml_request = ET.Element('tsRequest') - schedule_element = ET.SubElement(xml_request, 'schedule') + xml_request = ET.Element("tsRequest") + schedule_element = ET.SubElement(xml_request, "schedule") if schedule_item.name: - schedule_element.attrib['name'] = schedule_item.name + schedule_element.attrib["name"] = schedule_item.name if schedule_item.priority: - schedule_element.attrib['priority'] = str(schedule_item.priority) + schedule_element.attrib["priority"] = str(schedule_item.priority) if schedule_item.execution_order: - schedule_element.attrib['executionOrder'] = schedule_item.execution_order + schedule_element.attrib["executionOrder"] = schedule_item.execution_order if schedule_item.state: - schedule_element.attrib['state'] = schedule_item.state + schedule_element.attrib["state"] = schedule_item.state interval_item = schedule_item.interval_item if interval_item is not None: if interval_item._frequency: - schedule_element.attrib['frequency'] = interval_item._frequency - frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') - frequency_element.attrib['start'] = str(interval_item.start_time) - if hasattr(interval_item, 'end_time') and interval_item.end_time is not None: - frequency_element.attrib['end'] = str(interval_item.end_time) - intervals_element = ET.SubElement(frequency_element, 'intervals') - if hasattr(interval_item, 'interval'): + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + intervals_element = ET.SubElement(frequency_element, "intervals") + if hasattr(interval_item, "interval"): for interval in interval_item._interval_type_pairs(): (expression, value) = interval - single_interval_element = ET.SubElement(intervals_element, 'interval') + single_interval_element = ET.SubElement(intervals_element, "interval") single_interval_element.attrib[expression] = value return ET.tostring(xml_request) @@ -435,11 +441,11 @@ def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ - xml_request = ET.Element('tsRequest') - task_element = ET.SubElement(xml_request, 'task') + xml_request = ET.Element("tsRequest") + task_element = ET.SubElement(xml_request, "task") task = ET.SubElement(task_element, task_type) workbook = ET.SubElement(task, target_type) - workbook.attrib['id'] = id_ + workbook.attrib["id"] = id_ return ET.tostring(xml_request) @@ -452,371 +458,377 @@ def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): class SiteRequest(object): def update_req(self, site_item): - xml_request = ET.Element('tsRequest') - site_element = ET.SubElement(xml_request, 'site') + xml_request = ET.Element("tsRequest") + site_element = ET.SubElement(xml_request, "site") if site_item.name: - site_element.attrib['name'] = site_item.name + site_element.attrib["name"] = site_item.name if site_item.content_url: - site_element.attrib['contentUrl'] = site_item.content_url + site_element.attrib["contentUrl"] = site_item.content_url if site_item.admin_mode: - site_element.attrib['adminMode'] = site_item.admin_mode + site_element.attrib["adminMode"] = site_item.admin_mode if site_item.user_quota: - site_element.attrib['userQuota'] = str(site_item.user_quota) + site_element.attrib["userQuota"] = str(site_item.user_quota) if site_item.state: - site_element.attrib['state'] = site_item.state + site_element.attrib["state"] = site_item.state if site_item.storage_quota: - site_element.attrib['storageQuota'] = str(site_item.storage_quota) + site_element.attrib["storageQuota"] = str(site_item.storage_quota) if site_item.disable_subscriptions is not None: - site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() + site_element.attrib["disableSubscriptions"] = str(site_item.disable_subscriptions).lower() if site_item.subscribe_others_enabled is not None: - site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() + site_element.attrib["subscribeOthersEnabled"] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: - site_element.attrib['revisionLimit'] = str(site_item.revision_limit) + site_element.attrib["revisionLimit"] = str(site_item.revision_limit) if site_item.revision_history_enabled is not None: - site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() + site_element.attrib["revisionHistoryEnabled"] = str(site_item.revision_history_enabled).lower() if site_item.data_acceleration_mode is not None: - site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() + site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() if site_item.flows_enabled is not None: - site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() if site_item.cataloging_enabled is not None: - site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() + site_element.attrib["catalogingEnabled"] = str(site_item.cataloging_enabled).lower() if site_item.editing_flows_enabled is not None: - site_element.attrib['editingFlowsEnabled'] = str(site_item.editing_flows_enabled).lower() + site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() if site_item.scheduling_flows_enabled is not None: - site_element.attrib['schedulingFlowsEnabled'] = str(site_item.scheduling_flows_enabled).lower() + site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() if site_item.allow_subscription_attachments is not None: - site_element.attrib['allowSubscriptionAttachments'] = str(site_item.allow_subscription_attachments).lower() + site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: - site_element.attrib['guestAccessEnabled'] = str(site_item.guest_access_enabled).lower() + site_element.attrib["guestAccessEnabled"] = str(site_item.guest_access_enabled).lower() if site_item.cache_warmup_enabled is not None: - site_element.attrib['cacheWarmupEnabled'] = str(site_item.cache_warmup_enabled).lower() + site_element.attrib["cacheWarmupEnabled"] = str(site_item.cache_warmup_enabled).lower() if site_item.commenting_enabled is not None: - site_element.attrib['commentingEnabled'] = str(site_item.commenting_enabled).lower() + site_element.attrib["commentingEnabled"] = str(site_item.commenting_enabled).lower() if site_item.extract_encryption_mode is not None: - site_element.attrib['extractEncryptionMode'] = str(site_item.extract_encryption_mode).lower() + site_element.attrib["extractEncryptionMode"] = str(site_item.extract_encryption_mode).lower() if site_item.request_access_enabled is not None: - site_element.attrib['requestAccessEnabled'] = str(site_item.request_access_enabled).lower() + site_element.attrib["requestAccessEnabled"] = str(site_item.request_access_enabled).lower() if site_item.run_now_enabled is not None: - site_element.attrib['runNowEnabled'] = str(site_item.run_now_enabled).lower() + site_element.attrib["runNowEnabled"] = str(site_item.run_now_enabled).lower() if site_item.tier_creator_capacity is not None: - site_element.attrib['tierCreatorCapacity'] = str(site_item.tier_creator_capacity).lower() + site_element.attrib["tierCreatorCapacity"] = str(site_item.tier_creator_capacity).lower() if site_item.tier_explorer_capacity is not None: - site_element.attrib['tierExplorerCapacity'] = str(site_item.tier_explorer_capacity).lower() + site_element.attrib["tierExplorerCapacity"] = str(site_item.tier_explorer_capacity).lower() if site_item.tier_viewer_capacity is not None: - site_element.attrib['tierViewerCapacity'] = str(site_item.tier_viewer_capacity).lower() + site_element.attrib["tierViewerCapacity"] = str(site_item.tier_viewer_capacity).lower() if site_item.data_alerts_enabled is not None: - site_element.attrib['dataAlertsEnabled'] = str(site_item.data_alerts_enabled) + site_element.attrib["dataAlertsEnabled"] = str(site_item.data_alerts_enabled) if site_item.commenting_mentions_enabled is not None: - site_element.attrib['commentingMentionsEnabled'] = str(site_item.commenting_mentions_enabled).lower() + site_element.attrib["commentingMentionsEnabled"] = str(site_item.commenting_mentions_enabled).lower() if site_item.catalog_obfuscation_enabled is not None: - site_element.attrib['catalogObfuscationEnabled'] = str(site_item.catalog_obfuscation_enabled).lower() + site_element.attrib["catalogObfuscationEnabled"] = str(site_item.catalog_obfuscation_enabled).lower() if site_item.flow_auto_save_enabled is not None: - site_element.attrib['flowAutoSaveEnabled'] = str(site_item.flow_auto_save_enabled).lower() + site_element.attrib["flowAutoSaveEnabled"] = str(site_item.flow_auto_save_enabled).lower() if site_item.web_extraction_enabled is not None: - site_element.attrib['webExtractionEnabled'] = str(site_item.web_extraction_enabled).lower() + site_element.attrib["webExtractionEnabled"] = str(site_item.web_extraction_enabled).lower() if site_item.metrics_content_type_enabled is not None: - site_element.attrib['metricsContentTypeEnabled'] = str(site_item.metrics_content_type_enabled).lower() + site_element.attrib["metricsContentTypeEnabled"] = str(site_item.metrics_content_type_enabled).lower() if site_item.notify_site_admins_on_throttle is not None: - site_element.attrib['notifySiteAdminsOnThrottle'] = str(site_item.notify_site_admins_on_throttle).lower() + site_element.attrib["notifySiteAdminsOnThrottle"] = str(site_item.notify_site_admins_on_throttle).lower() if site_item.authoring_enabled is not None: - site_element.attrib['authoringEnabled'] = str(site_item.authoring_enabled).lower() + site_element.attrib["authoringEnabled"] = str(site_item.authoring_enabled).lower() if site_item.custom_subscription_email_enabled is not None: - site_element.attrib['customSubscriptionEmailEnabled'] = \ - str(site_item.custom_subscription_email_enabled).lower() + site_element.attrib["customSubscriptionEmailEnabled"] = str( + site_item.custom_subscription_email_enabled + ).lower() if site_item.custom_subscription_email is not None: - site_element.attrib['customSubscriptionEmail'] = str(site_item.custom_subscription_email).lower() + site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower() if site_item.custom_subscription_footer_enabled is not None: - site_element.attrib['customSubscriptionFooterEnabled'] =\ - str(site_item.custom_subscription_footer_enabled).lower() + site_element.attrib["customSubscriptionFooterEnabled"] = str( + site_item.custom_subscription_footer_enabled + ).lower() if site_item.custom_subscription_footer is not None: - site_element.attrib['customSubscriptionFooter'] = str(site_item.custom_subscription_footer).lower() + site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower() if site_item.ask_data_mode is not None: - site_element.attrib['askDataMode'] = str(site_item.ask_data_mode) + site_element.attrib["askDataMode"] = str(site_item.ask_data_mode) if site_item.named_sharing_enabled is not None: - site_element.attrib['namedSharingEnabled'] = str(site_item.named_sharing_enabled).lower() + site_element.attrib["namedSharingEnabled"] = str(site_item.named_sharing_enabled).lower() if site_item.mobile_biometrics_enabled is not None: - site_element.attrib['mobileBiometricsEnabled'] = str(site_item.mobile_biometrics_enabled).lower() + site_element.attrib["mobileBiometricsEnabled"] = str(site_item.mobile_biometrics_enabled).lower() if site_item.sheet_image_enabled is not None: - site_element.attrib['sheetImageEnabled'] = str(site_item.sheet_image_enabled).lower() + site_element.attrib["sheetImageEnabled"] = str(site_item.sheet_image_enabled).lower() if site_item.derived_permissions_enabled is not None: - site_element.attrib['derivedPermissionsEnabled'] = str(site_item.derived_permissions_enabled).lower() + site_element.attrib["derivedPermissionsEnabled"] = str(site_item.derived_permissions_enabled).lower() if site_item.user_visibility_mode is not None: - site_element.attrib['userVisibilityMode'] = str(site_item.user_visibility_mode) + site_element.attrib["userVisibilityMode"] = str(site_item.user_visibility_mode) if site_item.use_default_time_zone is not None: - site_element.attrib['useDefaultTimeZone'] = str(site_item.use_default_time_zone).lower() + site_element.attrib["useDefaultTimeZone"] = str(site_item.use_default_time_zone).lower() if site_item.time_zone is not None: - site_element.attrib['timeZone'] = str(site_item.time_zone) + site_element.attrib["timeZone"] = str(site_item.time_zone) if site_item.auto_suspend_refresh_enabled is not None: - site_element.attrib['autoSuspendRefreshEnabled'] = str(site_item.auto_suspend_refresh_enabled).lower() + site_element.attrib["autoSuspendRefreshEnabled"] = str(site_item.auto_suspend_refresh_enabled).lower() if site_item.auto_suspend_refresh_inactivity_window is not None: - site_element.attrib['autoSuspendRefreshInactivityWindow'] =\ - str(site_item.auto_suspend_refresh_inactivity_window) + site_element.attrib["autoSuspendRefreshInactivityWindow"] = str( + site_item.auto_suspend_refresh_inactivity_window + ) return ET.tostring(xml_request) def create_req(self, site_item): - xml_request = ET.Element('tsRequest') - site_element = ET.SubElement(xml_request, 'site') - site_element.attrib['name'] = site_item.name - site_element.attrib['contentUrl'] = site_item.content_url + xml_request = ET.Element("tsRequest") + site_element = ET.SubElement(xml_request, "site") + site_element.attrib["name"] = site_item.name + site_element.attrib["contentUrl"] = site_item.content_url if site_item.admin_mode: - site_element.attrib['adminMode'] = site_item.admin_mode + site_element.attrib["adminMode"] = site_item.admin_mode if site_item.user_quota: - site_element.attrib['userQuota'] = str(site_item.user_quota) + site_element.attrib["userQuota"] = str(site_item.user_quota) if site_item.storage_quota: - site_element.attrib['storageQuota'] = str(site_item.storage_quota) + site_element.attrib["storageQuota"] = str(site_item.storage_quota) if site_item.disable_subscriptions is not None: - site_element.attrib['disableSubscriptions'] = str(site_item.disable_subscriptions).lower() + site_element.attrib["disableSubscriptions"] = str(site_item.disable_subscriptions).lower() if site_item.subscribe_others_enabled is not None: - site_element.attrib['subscribeOthersEnabled'] = str(site_item.subscribe_others_enabled).lower() + site_element.attrib["subscribeOthersEnabled"] = str(site_item.subscribe_others_enabled).lower() if site_item.revision_limit: - site_element.attrib['revisionLimit'] = str(site_item.revision_limit) + site_element.attrib["revisionLimit"] = str(site_item.revision_limit) if site_item.data_acceleration_mode is not None: - site_element.attrib['dataAccelerationMode'] = str(site_item.data_acceleration_mode).lower() + site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() if site_item.flows_enabled is not None: - site_element.attrib['flowsEnabled'] = str(site_item.flows_enabled).lower() + site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() if site_item.editing_flows_enabled is not None: - site_element.attrib['editingFlowsEnabled'] = str(site_item.editing_flows_enabled).lower() + site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() if site_item.scheduling_flows_enabled is not None: - site_element.attrib['schedulingFlowsEnabled'] = str(site_item.scheduling_flows_enabled).lower() + site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() if site_item.allow_subscription_attachments is not None: - site_element.attrib['allowSubscriptionAttachments'] = str(site_item.allow_subscription_attachments).lower() + site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: - site_element.attrib['guestAccessEnabled'] = str(site_item.guest_access_enabled).lower() + site_element.attrib["guestAccessEnabled"] = str(site_item.guest_access_enabled).lower() if site_item.cache_warmup_enabled is not None: - site_element.attrib['cacheWarmupEnabled'] = str(site_item.cache_warmup_enabled).lower() + site_element.attrib["cacheWarmupEnabled"] = str(site_item.cache_warmup_enabled).lower() if site_item.commenting_enabled is not None: - site_element.attrib['commentingEnabled'] = str(site_item.commenting_enabled).lower() + site_element.attrib["commentingEnabled"] = str(site_item.commenting_enabled).lower() if site_item.revision_history_enabled is not None: - site_element.attrib['revisionHistoryEnabled'] = str(site_item.revision_history_enabled).lower() + site_element.attrib["revisionHistoryEnabled"] = str(site_item.revision_history_enabled).lower() if site_item.extract_encryption_mode is not None: - site_element.attrib['extractEncryptionMode'] = str(site_item.extract_encryption_mode).lower() + site_element.attrib["extractEncryptionMode"] = str(site_item.extract_encryption_mode).lower() if site_item.request_access_enabled is not None: - site_element.attrib['requestAccessEnabled'] = str(site_item.request_access_enabled).lower() + site_element.attrib["requestAccessEnabled"] = str(site_item.request_access_enabled).lower() if site_item.run_now_enabled is not None: - site_element.attrib['runNowEnabled'] = str(site_item.run_now_enabled).lower() + site_element.attrib["runNowEnabled"] = str(site_item.run_now_enabled).lower() if site_item.tier_creator_capacity is not None: - site_element.attrib['tierCreatorCapacity'] = str(site_item.tier_creator_capacity).lower() + site_element.attrib["tierCreatorCapacity"] = str(site_item.tier_creator_capacity).lower() if site_item.tier_explorer_capacity is not None: - site_element.attrib['tierExplorerCapacity'] = str(site_item.tier_explorer_capacity).lower() + site_element.attrib["tierExplorerCapacity"] = str(site_item.tier_explorer_capacity).lower() if site_item.tier_viewer_capacity is not None: - site_element.attrib['tierViewerCapacity'] = str(site_item.tier_viewer_capacity).lower() + site_element.attrib["tierViewerCapacity"] = str(site_item.tier_viewer_capacity).lower() if site_item.data_alerts_enabled is not None: - site_element.attrib['dataAlertsEnabled'] = str(site_item.data_alerts_enabled).lower() + site_element.attrib["dataAlertsEnabled"] = str(site_item.data_alerts_enabled).lower() if site_item.commenting_mentions_enabled is not None: - site_element.attrib['commentingMentionsEnabled'] = str(site_item.commenting_mentions_enabled).lower() + site_element.attrib["commentingMentionsEnabled"] = str(site_item.commenting_mentions_enabled).lower() if site_item.catalog_obfuscation_enabled is not None: - site_element.attrib['catalogObfuscationEnabled'] = str(site_item.catalog_obfuscation_enabled).lower() + site_element.attrib["catalogObfuscationEnabled"] = str(site_item.catalog_obfuscation_enabled).lower() if site_item.flow_auto_save_enabled is not None: - site_element.attrib['flowAutoSaveEnabled'] = str(site_item.flow_auto_save_enabled).lower() + site_element.attrib["flowAutoSaveEnabled"] = str(site_item.flow_auto_save_enabled).lower() if site_item.web_extraction_enabled is not None: - site_element.attrib['webExtractionEnabled'] = str(site_item.web_extraction_enabled).lower() + site_element.attrib["webExtractionEnabled"] = str(site_item.web_extraction_enabled).lower() if site_item.metrics_content_type_enabled is not None: - site_element.attrib['metricsContentTypeEnabled'] = str(site_item.metrics_content_type_enabled).lower() + site_element.attrib["metricsContentTypeEnabled"] = str(site_item.metrics_content_type_enabled).lower() if site_item.notify_site_admins_on_throttle is not None: - site_element.attrib['notifySiteAdminsOnThrottle'] = str(site_item.notify_site_admins_on_throttle).lower() + site_element.attrib["notifySiteAdminsOnThrottle"] = str(site_item.notify_site_admins_on_throttle).lower() if site_item.authoring_enabled is not None: - site_element.attrib['authoringEnabled'] = str(site_item.authoring_enabled).lower() + site_element.attrib["authoringEnabled"] = str(site_item.authoring_enabled).lower() if site_item.custom_subscription_email_enabled is not None: - site_element.attrib['customSubscriptionEmailEnabled'] =\ - str(site_item.custom_subscription_email_enabled).lower() + site_element.attrib["customSubscriptionEmailEnabled"] = str( + site_item.custom_subscription_email_enabled + ).lower() if site_item.custom_subscription_email is not None: - site_element.attrib['customSubscriptionEmail'] = str(site_item.custom_subscription_email).lower() + site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower() if site_item.custom_subscription_footer_enabled is not None: - site_element.attrib['customSubscriptionFooterEnabled'] =\ - str(site_item.custom_subscription_footer_enabled).lower() + site_element.attrib["customSubscriptionFooterEnabled"] = str( + site_item.custom_subscription_footer_enabled + ).lower() if site_item.custom_subscription_footer is not None: - site_element.attrib['customSubscriptionFooter'] = str(site_item.custom_subscription_footer).lower() + site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower() if site_item.ask_data_mode is not None: - site_element.attrib['askDataMode'] = str(site_item.ask_data_mode) + site_element.attrib["askDataMode"] = str(site_item.ask_data_mode) if site_item.named_sharing_enabled is not None: - site_element.attrib['namedSharingEnabled'] = str(site_item.named_sharing_enabled).lower() + site_element.attrib["namedSharingEnabled"] = str(site_item.named_sharing_enabled).lower() if site_item.mobile_biometrics_enabled is not None: - site_element.attrib['mobileBiometricsEnabled'] = str(site_item.mobile_biometrics_enabled).lower() + site_element.attrib["mobileBiometricsEnabled"] = str(site_item.mobile_biometrics_enabled).lower() if site_item.sheet_image_enabled is not None: - site_element.attrib['sheetImageEnabled'] = str(site_item.sheet_image_enabled).lower() + site_element.attrib["sheetImageEnabled"] = str(site_item.sheet_image_enabled).lower() if site_item.cataloging_enabled is not None: - site_element.attrib['catalogingEnabled'] = str(site_item.cataloging_enabled).lower() + site_element.attrib["catalogingEnabled"] = str(site_item.cataloging_enabled).lower() if site_item.derived_permissions_enabled is not None: - site_element.attrib['derivedPermissionsEnabled'] = str(site_item.derived_permissions_enabled).lower() + site_element.attrib["derivedPermissionsEnabled"] = str(site_item.derived_permissions_enabled).lower() if site_item.user_visibility_mode is not None: - site_element.attrib['userVisibilityMode'] = str(site_item.user_visibility_mode) + site_element.attrib["userVisibilityMode"] = str(site_item.user_visibility_mode) if site_item.use_default_time_zone is not None: - site_element.attrib['useDefaultTimeZone'] = str(site_item.use_default_time_zone).lower() + site_element.attrib["useDefaultTimeZone"] = str(site_item.use_default_time_zone).lower() if site_item.time_zone is not None: - site_element.attrib['timeZone'] = str(site_item.time_zone) + site_element.attrib["timeZone"] = str(site_item.time_zone) if site_item.auto_suspend_refresh_enabled is not None: - site_element.attrib['autoSuspendRefreshEnabled'] = str(site_item.auto_suspend_refresh_enabled).lower() + site_element.attrib["autoSuspendRefreshEnabled"] = str(site_item.auto_suspend_refresh_enabled).lower() if site_item.auto_suspend_refresh_inactivity_window is not None: - site_element.attrib['autoSuspendRefreshInactivityWindow'] =\ - str(site_item.auto_suspend_refresh_inactivity_window) + site_element.attrib["autoSuspendRefreshInactivityWindow"] = str( + site_item.auto_suspend_refresh_inactivity_window + ) return ET.tostring(xml_request) class TableRequest(object): def update_req(self, table_item): - xml_request = ET.Element('tsRequest') - table_element = ET.SubElement(xml_request, 'table') + xml_request = ET.Element("tsRequest") + table_element = ET.SubElement(xml_request, "table") if table_item.contact_id: - contact_element = ET.SubElement(table_element, 'contact') - contact_element.attrib['id'] = table_item.contact_id + contact_element = ET.SubElement(table_element, "contact") + contact_element.attrib["id"] = table_item.contact_id - table_element.attrib['isCertified'] = str(table_item.certified).lower() + table_element.attrib["isCertified"] = str(table_item.certified).lower() if table_item.certification_note: - table_element.attrib['certificationNote'] = str(table_item.certification_note) + table_element.attrib["certificationNote"] = str(table_item.certification_note) if table_item.description: - table_element.attrib['description'] = str(table_item.description) + table_element.attrib["description"] = str(table_item.description) return ET.tostring(xml_request) class TagRequest(object): def add_req(self, tag_set): - xml_request = ET.Element('tsRequest') - tags_element = ET.SubElement(xml_request, 'tags') + xml_request = ET.Element("tsRequest") + tags_element = ET.SubElement(xml_request, "tags") for tag in tag_set: - tag_element = ET.SubElement(tags_element, 'tag') - tag_element.attrib['label'] = tag + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag return ET.tostring(xml_request) class UserRequest(object): def update_req(self, user_item, password): - xml_request = ET.Element('tsRequest') - user_element = ET.SubElement(xml_request, 'user') + xml_request = ET.Element("tsRequest") + user_element = ET.SubElement(xml_request, "user") if user_item.fullname: - user_element.attrib['fullName'] = user_item.fullname + user_element.attrib["fullName"] = user_item.fullname if user_item.email: - user_element.attrib['email'] = user_item.email + user_element.attrib["email"] = user_item.email if user_item.site_role: - if user_item.site_role != 'ServerAdministrator': - user_element.attrib['siteRole'] = user_item.site_role + if user_item.site_role != "ServerAdministrator": + user_element.attrib["siteRole"] = user_item.site_role if user_item.auth_setting: - user_element.attrib['authSetting'] = user_item.auth_setting + user_element.attrib["authSetting"] = user_item.auth_setting if password: - user_element.attrib['password'] = password + user_element.attrib["password"] = password return ET.tostring(xml_request) def add_req(self, user_item): - xml_request = ET.Element('tsRequest') - user_element = ET.SubElement(xml_request, 'user') - user_element.attrib['name'] = user_item.name - user_element.attrib['siteRole'] = user_item.site_role + xml_request = ET.Element("tsRequest") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["name"] = user_item.name + user_element.attrib["siteRole"] = user_item.site_role if user_item.auth_setting: - user_element.attrib['authSetting'] = user_item.auth_setting + user_element.attrib["authSetting"] = user_item.auth_setting return ET.tostring(xml_request) class WorkbookRequest(object): - def _generate_xml( - self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None - ): - xml_request = ET.Element('tsRequest') - workbook_element = ET.SubElement(xml_request, 'workbook') - workbook_element.attrib['name'] = workbook_item.name + def _generate_xml(self, workbook_item, connection_credentials=None, connections=None, hidden_views=None): + xml_request = ET.Element("tsRequest") + workbook_element = ET.SubElement(xml_request, "workbook") + workbook_element.attrib["name"] = workbook_item.name if workbook_item.show_tabs: - workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() - project_element = ET.SubElement(workbook_element, 'project') - project_element.attrib['id'] = str(workbook_item.project_id) + workbook_element.attrib["showTabs"] = str(workbook_item.show_tabs).lower() + project_element = ET.SubElement(workbook_element, "project") + project_element.attrib["id"] = str(workbook_item.project_id) if connection_credentials is not None and connections is not None: - raise RuntimeError('You cannot set both `connections` and `connection_credentials`') + raise RuntimeError("You cannot set both `connections` and `connection_credentials`") if connection_credentials is not None: _add_credentials_element(workbook_element, connection_credentials) if connections is not None: - connections_element = ET.SubElement(workbook_element, 'connections') + connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) if hidden_views is not None: - views_element = ET.SubElement(workbook_element, 'views') + views_element = ET.SubElement(workbook_element, "views") for view_name in hidden_views: _add_hiddenview_element(views_element, view_name) return ET.tostring(xml_request) def update_req(self, workbook_item): - xml_request = ET.Element('tsRequest') - workbook_element = ET.SubElement(xml_request, 'workbook') + xml_request = ET.Element("tsRequest") + workbook_element = ET.SubElement(xml_request, "workbook") if workbook_item.name: - workbook_element.attrib['name'] = workbook_item.name + workbook_element.attrib["name"] = workbook_item.name if workbook_item.show_tabs is not None: - workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower() + workbook_element.attrib["showTabs"] = str(workbook_item.show_tabs).lower() if workbook_item.project_id: - project_element = ET.SubElement(workbook_element, 'project') - project_element.attrib['id'] = workbook_item.project_id + project_element = ET.SubElement(workbook_element, "project") + project_element.attrib["id"] = workbook_item.project_id if workbook_item.owner_id: - owner_element = ET.SubElement(workbook_element, 'owner') - owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.data_acceleration_config['acceleration_enabled'] is not None: + owner_element = ET.SubElement(workbook_element, "owner") + owner_element.attrib["id"] = workbook_item.owner_id + if workbook_item.data_acceleration_config["acceleration_enabled"] is not None: data_acceleration_config = workbook_item.data_acceleration_config - data_acceleration_element = ET.SubElement(workbook_element, 'dataAccelerationConfig') - data_acceleration_element.attrib['accelerationEnabled'] = str(data_acceleration_config - ["acceleration_enabled"]).lower() - if data_acceleration_config['accelerate_now'] is not None: - data_acceleration_element.attrib['accelerateNow'] = str(data_acceleration_config - ["accelerate_now"]).lower() + data_acceleration_element = ET.SubElement(workbook_element, "dataAccelerationConfig") + data_acceleration_element.attrib["accelerationEnabled"] = str( + data_acceleration_config["acceleration_enabled"] + ).lower() + if data_acceleration_config["accelerate_now"] is not None: + data_acceleration_element.attrib["accelerateNow"] = str( + data_acceleration_config["accelerate_now"] + ).lower() return ET.tostring(xml_request) def publish_req( - self, workbook_item, filename, file_contents, - connection_credentials=None, connections=None, hidden_views=None + self, workbook_item, filename, file_contents, connection_credentials=None, connections=None, hidden_views=None ): - xml_request = self._generate_xml(workbook_item, - connection_credentials=connection_credentials, - connections=connections, - hidden_views=hidden_views) - - parts = {'request_payload': ('', xml_request, 'text/xml'), - 'tableau_workbook': (filename, file_contents, 'application/octet-stream')} + xml_request = self._generate_xml( + workbook_item, + connection_credentials=connection_credentials, + connections=connections, + hidden_views=hidden_views, + ) + + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_workbook": (filename, file_contents, "application/octet-stream"), + } return _add_multipart(parts) - def publish_req_chunked( - self, workbook_item, connection_credentials=None, connections=None, - hidden_views=None - ): - xml_request = self._generate_xml(workbook_item, - connection_credentials=connection_credentials, - connections=connections, - hidden_views=hidden_views) + def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None, hidden_views=None): + xml_request = self._generate_xml( + workbook_item, + connection_credentials=connection_credentials, + connections=connections, + hidden_views=hidden_views, + ) - parts = {'request_payload': ('', xml_request, 'text/xml')} + parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) @_tsrequest_wrapped def embedded_extract_req(self, xml_request, include_all=True, datasources=None): - list_element = ET.SubElement(xml_request, 'datasources') + list_element = ET.SubElement(xml_request, "datasources") if include_all: - list_element.attrib['includeAll'] = "true" + list_element.attrib["includeAll"] = "true" else: for datasource_item in datasources: - datasource_element = list_element.SubElement(xml_request, 'datasource') - datasource_element.attrib['id'] = datasource_item.id + datasource_element = list_element.SubElement(xml_request, "datasource") + datasource_element.attrib["id"] = datasource_item.id class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request, connection_item): - connection_element = ET.SubElement(xml_request, 'connection') + connection_element = ET.SubElement(xml_request, "connection") if connection_item.server_address: - connection_element.attrib['serverAddress'] = connection_item.server_address.lower() + connection_element.attrib["serverAddress"] = connection_item.server_address.lower() if connection_item.server_port: - connection_element.attrib['serverPort'] = str(connection_item.server_port) + connection_element.attrib["serverPort"] = str(connection_item.server_port) if connection_item.username: - connection_element.attrib['userName'] = connection_item.username + connection_element.attrib["userName"] = connection_item.username if connection_item.password: - connection_element.attrib['password'] = connection_item.password + connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: - connection_element.attrib['embedPassword'] = str(connection_item.embed_password).lower() + connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() class TaskRequest(object): @@ -829,64 +841,64 @@ def run_req(self, xml_request, task_item): class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request, subscription_item): - subscription_element = ET.SubElement(xml_request, 'subscription') + subscription_element = ET.SubElement(xml_request, "subscription") # Main attributes - subscription_element.attrib['subject'] = subscription_item.subject + subscription_element.attrib["subject"] = subscription_item.subject if subscription_item.attach_image is not None: - subscription_element.attrib['attachImage'] = str(subscription_item.attach_image).lower() + subscription_element.attrib["attachImage"] = str(subscription_item.attach_image).lower() if subscription_item.attach_pdf is not None: - subscription_element.attrib['attachPdf'] = str(subscription_item.attach_pdf).lower() + subscription_element.attrib["attachPdf"] = str(subscription_item.attach_pdf).lower() if subscription_item.message is not None: - subscription_element.attrib['message'] = subscription_item.message + subscription_element.attrib["message"] = subscription_item.message if subscription_item.page_orientation is not None: - subscription_element.attrib['pageOrientation'] = subscription_item.page_orientation + subscription_element.attrib["pageOrientation"] = subscription_item.page_orientation if subscription_item.page_size_option is not None: - subscription_element.attrib['pageSizeOption'] = subscription_item.page_size_option + subscription_element.attrib["pageSizeOption"] = subscription_item.page_size_option # Content element - content_element = ET.SubElement(subscription_element, 'content') - content_element.attrib['id'] = subscription_item.target.id - content_element.attrib['type'] = subscription_item.target.type + content_element = ET.SubElement(subscription_element, "content") + content_element.attrib["id"] = subscription_item.target.id + content_element.attrib["type"] = subscription_item.target.type if subscription_item.send_if_view_empty is not None: - content_element.attrib['sendIfViewEmpty'] = str(subscription_item.send_if_view_empty).lower() + content_element.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower() # Schedule element - schedule_element = ET.SubElement(subscription_element, 'schedule') - schedule_element.attrib['id'] = subscription_item.schedule_id + schedule_element = ET.SubElement(subscription_element, "schedule") + schedule_element.attrib["id"] = subscription_item.schedule_id # User element - user_element = ET.SubElement(subscription_element, 'user') - user_element.attrib['id'] = subscription_item.user_id + user_element = ET.SubElement(subscription_element, "user") + user_element.attrib["id"] = subscription_item.user_id return ET.tostring(xml_request) @_tsrequest_wrapped def update_req(self, xml_request, subscription_item): - subscription = ET.SubElement(xml_request, 'subscription') + subscription = ET.SubElement(xml_request, "subscription") # Main attributes if subscription_item.subject is not None: - subscription.attrib['subject'] = subscription_item.subject + subscription.attrib["subject"] = subscription_item.subject if subscription_item.attach_image is not None: - subscription.attrib['attachImage'] = str(subscription_item.attach_image).lower() + subscription.attrib["attachImage"] = str(subscription_item.attach_image).lower() if subscription_item.attach_pdf is not None: - subscription.attrib['attachPdf'] = str(subscription_item.attach_pdf).lower() + subscription.attrib["attachPdf"] = str(subscription_item.attach_pdf).lower() if subscription_item.page_orientation is not None: - subscription.attrib['pageOrientation'] = subscription_item.page_orientation + subscription.attrib["pageOrientation"] = subscription_item.page_orientation if subscription_item.page_size_option is not None: - subscription.attrib['pageSizeOption'] = subscription_item.page_size_option + subscription.attrib["pageSizeOption"] = subscription_item.page_size_option if subscription_item.suspended is not None: - subscription.attrib['suspended'] = str(subscription_item.suspended).lower() + subscription.attrib["suspended"] = str(subscription_item.suspended).lower() # Schedule element - schedule = ET.SubElement(subscription, 'schedule') + schedule = ET.SubElement(subscription, "schedule") if subscription_item.schedule_id is not None: - schedule.attrib['id'] = subscription_item.schedule_id + schedule.attrib["id"] = subscription_item.schedule_id # Content element - content = ET.SubElement(subscription, 'content') + content = ET.SubElement(subscription, "content") if subscription_item.send_if_view_empty is not None: - content.attrib['sendIfViewEmpty'] = str(subscription_item.send_if_view_empty).lower() + content.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower() return ET.tostring(xml_request) @@ -899,16 +911,16 @@ def empty_req(self, xml_request): 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 + webhook = ET.SubElement(xml_request, "webhook") + webhook.attrib["name"] = webhook_item.name - source = ET.SubElement(webhook, 'webhook-source') + source = ET.SubElement(webhook, "webhook-source") 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 + 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) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 22d0a4ef0..23d10b3d6 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -8,11 +8,11 @@ def apply_query_params(self, url): params = self.get_query_params() params_list = ["{}={}".format(k, v) for (k, v) in params.items()] - if '?' in url: - url, existing_params = url.split('?') + if "?" in url: + url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, '&'.join(params_list)) + return "{0}?{1}".format(url, "&".join(params_list)) except NotImplementedError: raise @@ -22,44 +22,44 @@ def get_query_params(self): class RequestOptions(RequestOptionsBase): class Operator: - Equals = 'eq' - GreaterThan = 'gt' - GreaterThanOrEqual = 'gte' - LessThan = 'lt' - LessThanOrEqual = 'lte' - In = 'in' - Has = 'has' + Equals = "eq" + GreaterThan = "gt" + GreaterThanOrEqual = "gte" + LessThan = "lt" + LessThanOrEqual = "lte" + In = "in" + Has = "has" class Field: - Args = 'args' - CompletedAt = 'completedAt' - CreatedAt = 'createdAt' - DomainName = 'domainName' - DomainNickname = 'domainNickname' - HitsTotal = 'hitsTotal' - IsLocal = 'isLocal' - JobType = 'jobType' - LastLogin = 'lastLogin' - MinimumSiteRole = 'minimumSiteRole' - Name = 'name' - Notes = 'notes' - OwnerDomain = 'ownerDomain' - OwnerEmail = 'ownerEmail' - OwnerName = 'ownerName' - Progress = 'progress' - ProjectName = 'projectName' - SiteRole = 'siteRole' - Subtitle = 'subtitle' - Tags = 'tags' - Title = 'title' - TopLevelProject = 'topLevelProject' - Type = 'type' - UpdatedAt = 'updatedAt' - UserCount = 'userCount' + Args = "args" + CompletedAt = "completedAt" + CreatedAt = "createdAt" + DomainName = "domainName" + DomainNickname = "domainNickname" + HitsTotal = "hitsTotal" + IsLocal = "isLocal" + JobType = "jobType" + LastLogin = "lastLogin" + MinimumSiteRole = "minimumSiteRole" + Name = "name" + Notes = "notes" + OwnerDomain = "ownerDomain" + OwnerEmail = "ownerEmail" + OwnerName = "ownerName" + Progress = "progress" + ProjectName = "projectName" + SiteRole = "siteRole" + Subtitle = "subtitle" + Tags = "tags" + Title = "title" + TopLevelProject = "topLevelProject" + Type = "type" + UpdatedAt = "updatedAt" + UserCount = "userCount" class Direction: - Desc = 'desc' - Asc = 'asc' + Desc = "desc" + Asc = "asc" def __init__(self, pagenumber=1, pagesize=100): self.pagenumber = pagenumber @@ -81,19 +81,19 @@ def page_number(self, page_number): def get_query_params(self): params = {} if self.pagenumber: - params['pageNumber'] = self.pagenumber + params["pageNumber"] = self.pagenumber if self.pagesize: - params['pageSize'] = self.pagesize + params["pageSize"] = self.pagesize if len(self.sort) > 0: sort_options = (str(sort_item) for sort_item in self.sort) ordered_sort_options = sorted(sort_options) - params['sort'] = ','.join(ordered_sort_options) + params["sort"] = ",".join(ordered_sort_options) if len(self.filter) > 0: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) - params['filter'] = ','.join(ordered_filter_options) + params["filter"] = ",".join(ordered_filter_options) if self._all_fields: - params['fields'] = '_all_' + params["fields"] = "_all_" return params @@ -112,7 +112,7 @@ def vf(self, name, value): def _append_view_filters(self, params): for name, value in self.view_filters: - params['vf_' + name] = value + params["vf_" + name] = value class CSVRequestOptions(_FilterOptionsBase): @@ -132,7 +132,7 @@ def max_age(self, value): def get_query_params(self): params = {} if self.max_age != -1: - params['maxAge'] = self.max_age + params["maxAge"] = self.max_age self._append_view_filters(params) return params @@ -141,7 +141,7 @@ def get_query_params(self): class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: - High = 'high' + High = "high" def __init__(self, imageresolution=None, maxage=-1): super(ImageRequestOptions, self).__init__() @@ -160,9 +160,9 @@ def max_age(self, value): def get_query_params(self): params = {} if self.image_resolution: - params['resolution'] = self.image_resolution + params["resolution"] = self.image_resolution if self.max_age != -1: - params['maxAge'] = self.max_age + params["maxAge"] = self.max_age self._append_view_filters(params) return params @@ -205,13 +205,13 @@ def max_age(self, value): def get_query_params(self): params = {} if self.page_type: - params['type'] = self.page_type + params["type"] = self.page_type if self.orientation: - params['orientation'] = self.orientation + params["orientation"] = self.orientation if self.max_age != -1: - params['maxAge'] = self.max_age + params["maxAge"] = self.max_age self._append_view_filters(params) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 6aff0c126..b45098e8a 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,9 +2,29 @@ from .exceptions import NotSignedInError from ..namespace import Namespace -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ - Schedules, ServerInfo, Tasks, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows, Webhooks, DataAccelerationReport, Favorites, DataAlerts +from .endpoint import ( + Sites, + Views, + Users, + Groups, + Workbooks, + Datasources, + Projects, + Auth, + Schedules, + ServerInfo, + Tasks, + Subscriptions, + Jobs, + Metadata, + Databases, + Tables, + Flows, + Webhooks, + DataAccelerationReport, + Favorites, + DataAlerts, +) from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -14,20 +34,14 @@ except ImportError: from distutils.version import LooseVersion as Version -_PRODUCT_TO_REST_VERSION = { - '10.0': '2.3', - '9.3': '2.2', - '9.2': '2.1', - '9.1': '2.0', - '9.0': '2.0' -} +_PRODUCT_TO_REST_VERSION = {"10.0": "2.3", "9.3": "2.2", "9.2": "2.1", "9.1": "2.0", "9.0": "2.0"} class Server(object): class PublishMode: - Append = 'Append' - Overwrite = 'Overwrite' - CreateNew = 'CreateNew' + Append = "Append" + Overwrite = "Overwrite" + CreateNew = "CreateNew" def __init__(self, server_address, use_server_version=False): self._server_address = server_address @@ -84,8 +98,8 @@ def _set_auth(self, site_id, user_id, auth_token): def _get_legacy_version(self): response = self._session.get(self.server_address + "/auth?format=xml") info_xml = ET.fromstring(response.content) - prod_version = info_xml.find('.//product_version').text - version = _PRODUCT_TO_REST_VERSION.get(prod_version, '2.1') # 2.1 + prod_version = info_xml.find(".//product_version").text + version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 return version def _determine_highest_version(self): @@ -107,6 +121,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() import warnings + warnings.warn("use use_server_version instead", DeprecationWarning) def assert_at_least_version(self, version): @@ -114,7 +129,8 @@ def assert_at_least_version(self, version): minimum_supported = Version(version) if server_version < minimum_supported: error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported) + server_version, minimum_supported + ) raise EndpointUnavailableError(error) @property @@ -128,21 +144,21 @@ def namespace(self): @property def auth_token(self): if self._auth_token is None: - error = 'Missing authentication token. You must sign in first.' + error = "Missing authentication token. You must sign in first." raise NotSignedInError(error) return self._auth_token @property def site_id(self): if self._site_id is None: - error = 'Missing site ID. You must sign in first.' + error = "Missing site ID. You must sign in first." raise NotSignedInError(error) return self._site_id @property def user_id(self): if self._user_id is None: - error = 'Missing user ID. You must sign in first.' + error = "Missing user ID. You must sign in first." raise NotSignedInError(error) return self._user_id diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index f412b8aa3..2d6bc030a 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -4,4 +4,4 @@ def __init__(self, field, direction): self.direction = direction def __str__(self): - return '{0}:{1}'.format(self.field, self.direction) + return "{0}:{1}".format(self.field, self.direction) From 7443d337e3530e459263a2201572727516e75ddb Mon Sep 17 00:00:00 2001 From: t8y8 Date: Wed, 21 Apr 2021 13:32:39 -0700 Subject: [PATCH 232/567] remove pycode --- .github/workflows/run-tests.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a0917c7b6..45b9548c1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,13 +25,10 @@ jobs: pip install -e .[test] pip install mypy - - name: Lint with pycodestyle - run: | - pycodestyle tableauserverclient test samples - - name: Test with pytest run: | pytest test + - name: Run Mypy but allow failures run: | mypy --show-error-codes --disable-error-code misc tableauserverclient From 3769e0dd95500d1bfcea3869a29593f51b16588e Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Tue, 27 Apr 2021 13:36:50 -0700 Subject: [PATCH 233/567] Data Quality Warning Support (#836) Add support for DQWs by endpoint following the pattern used by permissions and tags -- leave by ID and Triggers dqw urls for another PR --- contributing.md | 6 +- tableauserverclient/__init__.py | 1 + tableauserverclient/_version.py | 23 ++- tableauserverclient/models/__init__.py | 9 +- tableauserverclient/models/data_alert_item.py | 6 +- tableauserverclient/models/database_item.py | 24 ++- tableauserverclient/models/datasource_item.py | 17 +- tableauserverclient/models/dqw_item.py | 148 ++++++++++++++++++ tableauserverclient/models/flow_item.py | 73 ++++++++- tableauserverclient/models/job_item.py | 35 ++++- .../models/personal_access_token_auth.py | 5 +- tableauserverclient/models/project_item.py | 23 ++- .../models/property_decorators.py | 7 +- tableauserverclient/models/schedule_item.py | 14 +- tableauserverclient/models/site_item.py | 5 +- tableauserverclient/models/table_item.py | 11 ++ tableauserverclient/models/tableau_auth.py | 10 +- tableauserverclient/models/task_item.py | 14 +- tableauserverclient/models/user_item.py | 51 +++++- tableauserverclient/models/workbook_item.py | 6 +- tableauserverclient/server/__init__.py | 8 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/databases_endpoint.py | 18 +++ .../server/endpoint/datasources_endpoint.py | 46 +++++- .../server/endpoint/dqw_endpoint.py | 61 ++++++++ .../server/endpoint/endpoint.py | 22 ++- .../server/endpoint/flows_endpoint.py | 18 +++ .../server/endpoint/groups_endpoint.py | 5 +- .../server/endpoint/permissions_endpoint.py | 7 +- .../server/endpoint/schedules_endpoint.py | 15 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/tables_endpoint.py | 23 ++- .../server/endpoint/tasks_endpoint.py | 10 +- .../server/endpoint/users_endpoint.py | 14 +- .../server/endpoint/workbooks_endpoint.py | 28 +++- tableauserverclient/server/request_factory.py | 99 +++++++++++- tableauserverclient/server/server.py | 13 +- test/assets/dqw_by_content_type.xml | 9 ++ test/test_database.py | 23 ++- 39 files changed, 852 insertions(+), 67 deletions(-) create mode 100644 tableauserverclient/models/dqw_item.py create mode 100644 tableauserverclient/server/endpoint/dqw_endpoint.py create mode 100644 test/assets/dqw_by_content_type.xml diff --git a/contributing.md b/contributing.md index c7f487ec3..3d5cd3d43 100644 --- a/contributing.md +++ b/contributing.md @@ -67,5 +67,9 @@ python setup.py test Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell -pycodestyle tableauserverclient test samples +# this will run the formatter without making changes +black --line-length 120 tableauserverclient --check + +# this will format the directory and code for you +black --line-length 120 tableauserverclient ``` diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 2eadcdfa1..fcce4e0c7 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -4,6 +4,7 @@ ConnectionItem, DataAlertItem, DatasourceItem, + DQWItem, GroupItem, JobItem, BackgroundJobItem, diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index c8afb10d4..5e73890bd 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -77,7 +77,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git p = subprocess.Popen( - [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + [c] + args, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr else None), ) break except EnvironmentError: @@ -243,7 +247,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) describe_out, rc = run_command( - GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root + GITS, + [ + "describe", + "--tags", + "--dirty", + "--always", + "--long", + "--match", + "%s*" % tag_prefix, + ], + cwd=root, ) # --long was added in git-1.5.5 if describe_out is None: @@ -285,7 +299,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + full_tag, + tag_prefix, + ) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index dff12a29d..c0ddc2e75 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,11 +5,18 @@ from .data_alert_item import DataAlertItem from .datasource_item import DatasourceItem from .database_item import DatabaseItem +from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError from .favorites_item import FavoriteItem from .group_item import GroupItem from .flow_item import FlowItem -from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval +from .interval_item import ( + IntervalItem, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, +) from .job_item import JobItem, BackgroundJobItem from .pagination_item import PaginationItem from .project_item import ProjectItem diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index c924b6ab2..d719469b0 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,6 +1,10 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_not_empty, property_is_enum, property_is_boolean +from .property_decorators import ( + property_not_empty, + property_is_enum, + property_is_boolean, +) from .user_item import UserItem from .view_item import ViewItem diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index a319606e4..4934af81b 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,6 +1,10 @@ import xml.etree.ElementTree as ET -from .property_decorators import property_is_enum, property_not_empty, property_is_boolean +from .property_decorators import ( + property_is_enum, + property_not_empty, + property_is_boolean, +) from .exceptions import UnpopulatedPropertyError @@ -34,8 +38,17 @@ def __init__(self, name, description=None, content_permissions=None): self._permissions = None self._default_table_permissions = None + self._data_quality_warnings = None + self._tables = None # Not implemented yet + @property + def dqws(self): + if self._data_quality_warnings is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._data_quality_warnings() + @property def content_permissions(self): return self._content_permissions @@ -229,7 +242,14 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - setattr(self, "_default_{content}_permissions".format(content=content_type), permissions) + setattr( + self, + "_default_{content}_permissions".format(content=content_type), + permissions, + ) + + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 219df39c2..78c2a44ca 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,6 +1,10 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_enum +from .property_decorators import ( + property_not_nullable, + property_is_boolean, + property_is_enum, +) from .tag_item import TagItem from ..datetime_helpers import parse_datetime import copy @@ -35,6 +39,7 @@ def __init__(self, project_id, name=None): self.tags = set() self._permissions = None + self._data_quality_warnings = None @property def ask_data_enablement(self): @@ -94,6 +99,13 @@ def encrypt_extracts(self): def encrypt_extracts(self, value): self._encrypt_extracts = value + @property + def dqws(self): + if self._data_quality_warnings is None: + error = "Project item must be populated with dqws first." + raise UnpopulatedPropertyError(error) + return self._data_quality_warnings() + @property def has_extracts(self): return self._has_extracts @@ -142,6 +154,9 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions + def _set_data_quality_warnings(self, dqws): + self._data_quality_warnings = dqws + def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): datasource_xml = ET.fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py new file mode 100644 index 000000000..a7f8ec9cb --- /dev/null +++ b/tableauserverclient/models/dqw_item.py @@ -0,0 +1,148 @@ +import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime + + +class DQWItem(object): + class WarningType: + WARNING = "WARNING" + DEPRECATED = "DEPRECATED" + STALE = "STALE" + SENSITIVE_DATA = "SENSITIVE_DATA" + MAINTENANCE = "MAINTENANCE" + + def __init__(self, warning_type="WARNING", message=None, active=True, severe=False): + self._id = None + # content related + self._content_id = None + self._content_type = None + + # DQW related + self.warning_type = warning_type + self.message = message + self.active = active + self.severe = severe + self._created_at = None + self._updated_at = None + + # owner + self._owner_display_name = None + self._owner_id = None + + @property + def id(self): + return self._id + + @property + def content_id(self): + return self._content_id + + @property + def content_type(self): + return self._content_type + + @property + def owner_display_name(self): + return self._owner_display_name + + @property + def owner_id(self): + return self._owner_id + + @property + def warning_type(self): + return self._warning_type + + @warning_type.setter + def warning_type(self, value): + self._warning_type = value + + @property + def message(self): + return self._message + + @message.setter + def message(self, value): + self._message = value + + @property + def active(self): + return self._active + + @active.setter + def active(self, value): + self._active = value + + @property + def severe(self): + return self._severe + + @severe.setter + def severe(self, value): + self._severe = value + + @property + def active(self): + return self._active + + @active.setter + def active(self, value): + self._active = value + + @property + def created_at(self): + return self._created_at + + @created_at.setter + def created_at(self, value): + self._created_at = value + + @property + def updated_at(self): + return self._updated_at + + @updated_at.setter + def updated_at(self, value): + self._updated_at = value + + @classmethod + def from_response(cls, resp, ns): + return cls.from_xml_element(ET.fromstring(resp), ns) + + @classmethod + def from_xml_element(cls, parsed_response, ns): + all_dqws = [] + dqw_elem_list = parsed_response.findall(".//t:dataQualityWarning", namespaces=ns) + for dqw_elem in dqw_elem_list: + dqw = DQWItem() + dqw._id = dqw_elem.get("id", None) + dqw._owner_display_name = dqw_elem.get("userDisplayName", None) + dqw._content_id = dqw_elem.get("contentId", None) + dqw._content_type = dqw_elem.get("contentType", None) + dqw.message = dqw_elem.get("message", None) + dqw.warning_type = dqw_elem.get("type", None) + + is_active = dqw_elem.get("isActive", None) + if is_active is not None: + dqw._active = string_to_bool(is_active) + + is_severe = dqw_elem.get("isSevere", None) + if is_severe is not None: + dqw._severe = string_to_bool(is_severe) + + dqw._created_at = parse_datetime(dqw_elem.get("createdAt", None)) + dqw._updated_at = parse_datetime(dqw_elem.get("updatedAt", None)) + + owner_id = None + owner_tag = dqw_elem.find(".//t:owner", namespaces=ns) + if owner_tag is not None: + owner_id = owner_tag.get("id", None) + dqw._owner_id = owner_id + + all_dqws.append(dqw) + + return all_dqws + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s): + return s.lower() == "true" diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 99e857369..d1387f368 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -22,6 +22,7 @@ def __init__(self, project_id, name=None): self._connections = None self._permissions = None + self._data_quality_warnings = None @property def connections(self): @@ -45,6 +46,13 @@ def webpage_url(self): def created_at(self): return self._created_at + @property + def dqws(self): + if self._data_quality_warnings is None: + error = "Project item must be populated with dqws first." + raise UnpopulatedPropertyError(error) + return self._data_quality_warnings() + @property def id(self): return self._id @@ -84,16 +92,51 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions + def _set_data_quality_warnings(self, dqws): + self._data_quality_warnings = dqws + def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): flow_xml = ET.fromstring(flow_xml).find(".//t:flow", namespaces=ns) if flow_xml is not None: - (_, _, _, _, _, updated_at, _, project_id, project_name, owner_id) = self._parse_element(flow_xml, ns) - self._set_values(None, None, None, None, None, updated_at, None, project_id, project_name, owner_id) + ( + _, + _, + _, + _, + _, + updated_at, + _, + project_id, + project_name, + owner_id, + ) = self._parse_element(flow_xml, ns) + self._set_values( + None, + None, + None, + None, + None, + updated_at, + None, + project_id, + project_name, + owner_id, + ) return self def _set_values( - self, id, name, description, webpage_url, created_at, updated_at, tags, project_id, project_name, owner_id + self, + id, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + project_id, + project_name, + owner_id, ): if id is not None: self._id = id @@ -138,7 +181,16 @@ def from_response(cls, resp, ns): ) = cls._parse_element(flow_xml, ns) flow_item = cls(project_id) flow_item._set_values( - id_, name, description, webpage_url, created_at, updated_at, tags, None, project_name, owner_id + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + None, + project_name, + owner_id, ) all_flow_items.append(flow_item) return all_flow_items @@ -169,4 +221,15 @@ def _parse_element(flow_xml, ns): if owner_elem is not None: owner_id = owner_elem.get("id", None) - return (id_, name, description, webpage_url, created_at, updated_at, tags, project_id, project_name, owner_id) + return ( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + project_id, + project_name, + owner_id, + ) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 7b7ea4921..7a3a50861 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -92,7 +92,17 @@ def _parse_element(cls, element, ns): finish_code = element.get("finishCode", -1) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None mode = element.get("mode", None) - return cls(id_, type_, progress, created_at, started_at, completed_at, finish_code, notes, mode) + return cls( + id_, + type_, + progress, + created_at, + started_at, + completed_at, + finish_code, + notes, + mode, + ) class BackgroundJobItem(object): @@ -104,7 +114,16 @@ class Status: Cancelled = "Cancelled" def __init__( - self, id_, created_at, priority, job_type, status, title=None, subtitle=None, started_at=None, ended_at=None + self, + id_, + created_at, + priority, + job_type, + status, + title=None, + subtitle=None, + started_at=None, + ended_at=None, ): self._id = id_ self._type = job_type @@ -176,4 +195,14 @@ def _parse_element(cls, element, ns): title = element.get("title", None) subtitle = element.get("subtitle", None) - return cls(id_, created_at, priority, type_, status, title, subtitle, started_at, ended_at) + return cls( + id_, + created_at, + priority, + type_, + status, + title, + subtitle, + started_at, + ended_at, + ) diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index c80a020e8..a95972164 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -8,7 +8,10 @@ def __init__(self, token_name, personal_access_token, site_id=""): @property def credentials(self): - return {"personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token} + 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/project_item.py b/tableauserverclient/models/project_item.py index e8434a0ad..bed6def6e 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -90,7 +90,13 @@ def _parse_common_tags(self, project_xml, ns): project_xml = ET.fromstring(project_xml).find(".//t:project", namespaces=ns) if project_xml is not None: - (_, name, description, content_permissions, parent_id) = self._parse_element(project_xml) + ( + _, + name, + description, + content_permissions, + parent_id, + ) = self._parse_element(project_xml) self._set_values(None, name, description, content_permissions, parent_id) return self @@ -112,7 +118,11 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - setattr(self, "_default_{content}_permissions".format(content=content_type), permissions) + setattr( + self, + "_default_{content}_permissions".format(content=content_type), + permissions, + ) @classmethod def from_response(cls, resp, ns): @@ -121,7 +131,14 @@ def from_response(cls, resp, ns): all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - (id, name, description, content_permissions, parent_id, owner_id) = cls._parse_element(project_xml) + ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + ) = cls._parse_element(project_xml) project_item = cls(name) project_item._set_values(id, name, description, content_permissions, parent_id, owner_id) all_project_items.append(project_item) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 153786d4c..b3466dea7 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -152,7 +152,12 @@ def wrapper(self, value): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) if len(value) != 4 or not all( attr in value.keys() - for attr in ("acceleration_enabled", "accelerate_now", "last_updated_at", "acceleration_status") + for attr in ( + "acceleration_enabled", + "accelerate_now", + "last_updated_at", + "acceleration_status", + ) ): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index b54c20ae9..f8baf0749 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -1,8 +1,18 @@ import xml.etree.ElementTree as ET from datetime import datetime -from .interval_item import IntervalItem, HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval -from .property_decorators import property_is_enum, property_not_nullable, property_is_int +from .interval_item import ( + IntervalItem, + HourlyInterval, + DailyInterval, + WeeklyInterval, + MonthlyInterval, +) +from .property_decorators import ( + property_is_enum, + property_not_nullable, + property_is_int, +) from ..datetime_helpers import parse_datetime diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index ac20e6a89..7fb1d116e 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -138,7 +138,10 @@ def content_url(self): @content_url.setter @property_not_nullable - @property_matches(VALID_CONTENT_URL_RE, "content_url can contain only letters, numbers, dashes, and underscores") + @property_matches( + VALID_CONTENT_URL_RE, + "content_url can contain only letters, numbers, dashes, and underscores", + ) def content_url(self, value): self._content_url = value diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 16f43b98e..2f47400f7 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -17,6 +17,7 @@ def __init__(self, name, description=None): self._schema = None self._columns = None + self._data_quality_warnings = None @property def permissions(self): @@ -25,6 +26,13 @@ def permissions(self): raise UnpopulatedPropertyError(error) return self._permissions() + @property + def dqws(self): + if self._data_quality_warnings is None: + error = "Project item must be populated with dqws first." + raise UnpopulatedPropertyError(error) + return self._data_quality_warnings() + @property def id(self): return self._id @@ -86,6 +94,9 @@ def columns(self): def _set_columns(self, columns): self._columns = columns + def _set_data_quality_warnings(self, dqws): + self._data_quality_warnings = dqws + def _set_values(self, table_values): if "id" in table_values: self._id = table_values["id"] diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 53aa33992..01787de4e 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -18,14 +18,20 @@ def __init__(self, username, password, site=None, site_id="", user_id_to_imperso def site(self): import warnings - warnings.warn("TableauAuth.site is deprecated, use TableauAuth.site_id instead.", DeprecationWarning) + warnings.warn( + "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", + DeprecationWarning, + ) return self.site_id @site.setter def site(self, value): import warnings - warnings.warn("TableauAuth.site is deprecated, use TableauAuth.site_id instead.", DeprecationWarning) + warnings.warn( + "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", + DeprecationWarning, + ) self.site_id = value @property diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 26b0f348f..65709d5c9 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -10,7 +10,10 @@ class Type: DataAcceleration = "dataAcceleration" # This mapping is used to convert task type returned from server - _TASK_TYPE_MAPPING = {"RefreshExtractTask": Type.ExtractRefresh, "MaterializeViewsTask": Type.DataAcceleration} + _TASK_TYPE_MAPPING = { + "RefreshExtractTask": Type.ExtractRefresh, + "MaterializeViewsTask": Type.DataAcceleration, + } def __init__( self, @@ -78,7 +81,14 @@ def _parse_element(cls, element, ns): consecutive_failed_count = int(element.get("consecutiveFailedCount", 0)) id_ = element.get("id", None) return cls( - id_, task_type, priority, consecutive_failed_count, schedule_item.id, schedule_item, last_run_at, target + id_, + task_type, + priority, + consecutive_failed_count, + schedule_item.id, + schedule_item, + last_run_at, + target, ) @staticmethod diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b9796cbae..65abf4cb6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,6 +1,10 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_is_enum, property_not_empty, property_not_nullable +from .property_decorators import ( + property_is_enum, + property_not_empty, + property_not_nullable, +) from ..datetime_helpers import parse_datetime from .reference_item import ResourceReference @@ -128,12 +132,31 @@ def _parse_common_tags(self, user_xml, ns): if not isinstance(user_xml, ET.Element): user_xml = ET.fromstring(user_xml).find(".//t:user", namespaces=ns) if user_xml is not None: - (_, _, site_role, _, _, fullname, email, auth_setting, _) = self._parse_element(user_xml, ns) + ( + _, + _, + site_role, + _, + _, + fullname, + email, + auth_setting, + _, + ) = self._parse_element(user_xml, ns) self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) return self def _set_values( - self, id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + self, + id, + name, + site_role, + last_login, + external_auth_user_id, + fullname, + email, + auth_setting, + domain_name, ): if id is not None: self._id = id @@ -173,7 +196,15 @@ def from_response(cls, resp, ns): ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( - id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + id, + name, + site_role, + last_login, + external_auth_user_id, + fullname, + email, + auth_setting, + domain_name, ) all_user_items.append(user_item) return all_user_items @@ -198,7 +229,17 @@ def _parse_element(user_xml, ns): if domain_elem is not None: domain_name = domain_elem.get("name", None) - return id, name, site_role, last_login, external_auth_user_id, fullname, email, auth_setting, domain_name + return ( + id, + name, + site_role, + last_login, + external_auth_user_id, + fullname, + email, + auth_setting, + domain_name, + ) def __repr__(self): return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 20597364e..14ca8f33b 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,6 +1,10 @@ import xml.etree.ElementTree as ET from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_not_nullable, property_is_boolean, property_is_data_acceleration_config +from .property_decorators import ( + property_not_nullable, + property_is_boolean, + property_is_data_acceleration_config, +) from .tag_item import TagItem from .view_item import ViewItem from .permissions_item import PermissionsRule diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 314b477c2..c653a8966 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,5 +1,10 @@ from .request_factory import RequestFactory -from .request_options import CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions +from .request_options import ( + CSVRequestOptions, + ImageRequestOptions, + PDFRequestOptions, + RequestOptions, +) from .filter import Filter from .sort import Sort from .. import ( @@ -7,6 +12,7 @@ DataAlertItem, DatasourceItem, DatabaseItem, + DQWItem, JobItem, BackgroundJobItem, GroupItem, diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 5d55509cf..8653c0254 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -6,7 +6,11 @@ from .endpoint import Endpoint from .favorites_endpoint import Favorites from .flows_endpoint import Flows -from .exceptions import ServerResponseError, MissingRequiredFieldError, ServerInfoEndpointNotFoundError +from .exceptions import ( + ServerResponseError, + MissingRequiredFieldError, + ServerInfoEndpointNotFoundError, +) from .groups_endpoint import Groups from .jobs_endpoint import Jobs from .metadata_endpoint import Metadata diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index f9ff014d9..50826ee0b 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -2,6 +2,7 @@ from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission @@ -16,6 +17,7 @@ def __init__(self, parent_srv): self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, "database") @property def baseurl(self): @@ -116,3 +118,19 @@ def update_table_default_permissions(self, item): @api(version="3.5") def delete_table_default_permissions(self, item): self._default_permissions.delete_default_permissions(item, Permission.Resource.Table) + + @api(version="3.5") + def populate_dqw(self, item): + self._data_quality_warnings.populate(item) + + @api(version="3.5") + def update_dqw(self, item, warning): + return self._data_quality_warnings.update(item, warning) + + @api(version="3.5") + def add_dqw(self, item, warning): + return self._data_quality_warnings.add(item, warning) + + @api(version="3.5") + def delete_dqw(self, item): + self._data_quality_warnings.clear(item) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6d117b2a0..ccdbfa0d1 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,11 +1,17 @@ from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem from ..query import QuerySet -from ...filesys_helpers import to_filename, make_download_path, get_file_type, get_file_object_size +from ...filesys_helpers import ( + to_filename, + make_download_path, + get_file_type, + get_file_object_size, +) from ...models.job_item import JobItem import os @@ -27,6 +33,7 @@ def __init__(self, parent_srv): super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @property def baseurl(self): @@ -95,7 +102,10 @@ def download(self, datasource_id, filepath=None, include_extract=True, no_extrac if no_extract is False or no_extract is True: import warnings - warnings.warn("no_extract is deprecated, use include_extract instead.", DeprecationWarning) + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) include_extract = not no_extract if not include_extract: @@ -174,7 +184,15 @@ def delete_extract(self, datasource_item): @api(version="2.0") @parameter_added_in(connections="2.8") @parameter_added_in(as_job="3.0") - def publish(self, datasource_item, file, mode, connection_credentials=None, connections=None, as_job=False): + def publish( + self, + datasource_item, + file, + mode, + connection_credentials=None, + connections=None, + as_job=False, + ): try: @@ -241,7 +259,11 @@ def publish(self, datasource_item, file, mode, connection_credentials=None, conn file_contents = file.read() xml_request, content_type = RequestFactory.Datasource.publish_req( - datasource_item, filename, file_contents, connection_credentials, connections + datasource_item, + filename, + file_contents, + connection_credentials, + connections, ) # Send the publishing request to server @@ -287,3 +309,19 @@ def update_permissions(self, item, permission_item): @api(version="2.0") def delete_permission(self, item, capability_item): self._permissions.delete(item, capability_item) + + @api(version="3.5") + def populate_dqw(self, item): + self._data_quality_warnings.populate(item) + + @api(version="3.5") + def update_dqw(self, item, warning): + return self._data_quality_warnings.update(item, warning) + + @api(version="3.5") + def add_dqw(self, item, warning): + return self._data_quality_warnings.add(item, warning) + + @api(version="3.5") + def delete_dqw(self, item): + self._data_quality_warnings.clear(item) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py new file mode 100644 index 000000000..e19ca7d90 --- /dev/null +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -0,0 +1,61 @@ +import logging + +from .. import RequestFactory, DQWItem + +from .endpoint import Endpoint +from .exceptions import MissingRequiredFieldError + + +logger = logging.getLogger(__name__) + + +class _DataQualityWarningEndpoint(Endpoint): + def __init__(self, parent_srv, resource_type): + super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + self.resource_type = resource_type + + @property + def baseurl(self): + return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type + ) + + def add(self, resource, warning): + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + add_req = RequestFactory.DQW.add_req(warning) + response = self.post_request(url, add_req) + warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) + logger.info("Added dqw for resource {0}".format(resource.id)) + + return warnings + + def update(self, resource, warning): + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + add_req = RequestFactory.DQW.update_req(warning) + response = self.put_request(url, add_req) + warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) + logger.info("Added dqw for resource {0}".format(resource.id)) + + return warnings + + def clear(self, resource): + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + return self.delete_request(url) + + def populate(self, item): + if not item.id: + error = "Server item is missing ID. Item must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def dqw_fetcher(): + return self._get_data_quality_warnings(item) + + item._set_data_quality_warnings(dqw_fetcher) + logger.info("Populated permissions for item (ID: {0})".format(item.id)) + + def _get_data_quality_warnings(self, item, req_options=None): + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + server_response = self.get_request(url, req_options) + dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) + + return dqws diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 92a20b21a..c7be8fc77 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,9 @@ -from .exceptions import ServerResponseError, InternalServerError, NonXMLResponseError, EndpointUnavailableError +from .exceptions import ( + ServerResponseError, + InternalServerError, + NonXMLResponseError, + EndpointUnavailableError, +) from functools import wraps from xml.etree.ElementTree import ParseError from ..query import QuerySet @@ -39,7 +44,15 @@ def _safe_to_log(server_response): else: return server_response.content - def _make_request(self, method, url, content=None, auth_token=None, content_type=None, parameters=None): + def _make_request( + self, + method, + url, + content=None, + auth_token=None, + content_type=None, + parameters=None, + ): parameters = parameters or {} parameters.update(self.parent_srv.http_options) parameters["headers"] = Endpoint._make_common_headers(auth_token, content_type) @@ -95,7 +108,10 @@ def get_request(self, url, request_object=None, parameters=None): url = request_object.apply_query_params(url) return self._make_request( - self.parent_srv.session.get, url, auth_token=self.parent_srv.auth_token, parameters=parameters + self.parent_srv.session.get, + url, + auth_token=self.parent_srv.auth_token, + parameters=parameters, ) def delete_request(self, url): diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 41cbe19cd..475166aad 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import Endpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem @@ -26,6 +27,7 @@ def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self): @@ -214,3 +216,19 @@ def update_permissions(self, item, permission_item): @api(version="3.3") def delete_permission(self, item, capability_item): self._permissions.delete(item, capability_item) + + @api(version="3.5") + def populate_dqw(self, item): + self._data_quality_warnings.populate(item) + + @api(version="3.5") + def update_dqw(self, item, warning): + return self._data_quality_warnings.update(item, warning) + + @api(version="3.5") + def add_dqw(self, item, warning): + return self._data_quality_warnings.add(item, warning) + + @api(version="3.5") + def delete_dqw(self, item): + self._data_quality_warnings.clear(item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 4a09872cb..b771e56d8 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -33,7 +33,10 @@ def populate_users(self, group_item, req_options=None): # Define an inner function that we bind to the model_item's `.user` property. def user_pager(): - return Pager(lambda options: self._get_users_for_group(group_item, options), req_options) + return Pager( + lambda options: self._get_users_for_group(group_item, options), + req_options, + ) group_item._set_users(user_pager) diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 0992f5ca9..7035837f4 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -46,7 +46,12 @@ def delete(self, resource, rules): for capability, mode in rule.capabilities.items(): " /permissions/groups/group-id/capability-name/capability-mode" url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( - self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", rule.grantee.id, capability, mode + self.owner_baseurl(), + resource.id, + rule.grantee.tag_name + "s", + rule.grantee.id, + capability, + mode, ) logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 3a5e665fa..d582dca26 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -65,7 +65,13 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") - def add_to_schedule(self, schedule_id, workbook=None, datasource=None, task_type=TaskItem.Type.ExtractRefresh): + def add_to_schedule( + self, + schedule_id, + workbook=None, + datasource=None, + task_type=TaskItem.Type.ExtractRefresh, + ): def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) @@ -79,7 +85,12 @@ def add_to(resource, type_, req_factory): logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) if error is not None or warnings is not None: - return AddResponse(result=False, error=error, warnings=warnings, task_created=task_created) + return AddResponse( + result=False, + error=error, + warnings=warnings, + task_created=task_created, + ) else: return OK diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 98d996b52..8776477d3 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,9 @@ from .endpoint import Endpoint, api -from .exceptions import ServerResponseError, ServerInfoEndpointNotFoundError, EndpointUnavailableError +from .exceptions import ( + ServerResponseError, + ServerInfoEndpointNotFoundError, + EndpointUnavailableError, +) from ...models import ServerInfoItem import logging diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e35535d19..ac53484db 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,6 +1,7 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from ..pager import Pager from .. import RequestFactory, TableItem, ColumnItem, PaginationItem @@ -15,6 +16,7 @@ def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): @@ -70,7 +72,10 @@ def populate_columns(self, table_item, req_options=None): raise MissingRequiredFieldError(error) def column_fetcher(): - return Pager(lambda options: self._get_columns_for_table(table_item, options), req_options) + return Pager( + lambda options: self._get_columns_for_table(table_item, options), + req_options, + ) table_item._set_columns(column_fetcher) logger.info("Populated columns for table (ID: {0}".format(table_item.id)) @@ -113,3 +118,19 @@ def update_permissions(self, item, rules): @api(version="3.5") def delete_permission(self, item, rules): return self._permissions.delete(item, rules) + + @api(version="3.5") + def populate_dqw(self, item): + self._data_quality_warnings.populate(item) + + @api(version="3.5") + def update_dqw(self, item, warning): + return self._data_quality_warnings.update(item, warning) + + @api(version="3.5") + def add_dqw(self, item, warning): + return self._data_quality_warnings.add(item, warning) + + @api(version="3.5") + def delete_dqw(self, item): + self._data_quality_warnings.clear(item) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index abc249721..aaa5069c3 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -42,7 +42,11 @@ def get_by_id(self, task_id): error = "No Task ID provided" raise ValueError(error) logger.info("Querying a single task by id ({})".format(task_id)) - url = "{}/{}/{}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) + url = "{}/{}/{}".format( + self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), + task_id, + ) server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -53,7 +57,9 @@ def run(self, task_item): raise MissingRequiredFieldError(error) url = "{0}/{1}/{2}/runNow".format( - self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id + self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), + task_item.id, ) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3318e6bb3..6adbf92fb 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,13 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem +from .. import ( + RequestFactory, + RequestOptions, + UserItem, + WorkbookItem, + PaginationItem, + GroupItem, +) from ..pager import Pager import copy @@ -105,7 +112,10 @@ def populate_groups(self, user_item, req_options=None): raise MissingRequiredFieldError(error) def groups_for_user_pager(): - return Pager(lambda options: self._get_groups_for_user(user_item, options), req_options) + return Pager( + lambda options: self._get_groups_for_user(user_item, options), + req_options, + ) user_item._set_groups(groups_for_user_pager) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index aa72979dd..df14674c6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -5,7 +5,12 @@ from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.job_item import JobItem -from ...filesys_helpers import to_filename, make_download_path, get_file_type, get_file_object_size +from ...filesys_helpers import ( + to_filename, + make_download_path, + get_file_type, + get_file_object_size, +) import os import logging @@ -140,7 +145,10 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= if no_extract is False or no_extract is True: import warnings - warnings.warn("no_extract is deprecated, use include_extract instead.", DeprecationWarning) + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) include_extract = not no_extract if not include_extract: @@ -176,7 +184,11 @@ def _get_views_for_workbook(self, workbook_item, usage): if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) - views = ViewItem.from_response(server_response.content, self.parent_srv.namespace, workbook_id=workbook_item.id) + views = ViewItem.from_response( + server_response.content, + self.parent_srv.namespace, + workbook_id=workbook_item.id, + ) return views # Get all connections of workbook @@ -267,7 +279,10 @@ def publish( if connection_credentials is not None: import warnings - warnings.warn("connection_credentials is being deprecated. Use connections instead", DeprecationWarning) + warnings.warn( + "connection_credentials is being deprecated. Use connections instead", + DeprecationWarning, + ) try: # Expect file to be a filepath @@ -333,7 +348,10 @@ def publish( url = "{0}&uploadSessionId={1}".format(url, upload_session_id) conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( - workbook_item, connection_credentials=conn_creds, connections=connections, hidden_views=hidden_views + workbook_item, + connection_credentials=conn_creds, + connections=connections, + hidden_views=hidden_views, ) else: logger.info("Publishing {0} to server".format(filename)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index d2e921479..c03a4fadc 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -177,7 +177,14 @@ def update_req(self, datasource_item): return ET.tostring(xml_request) - def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None): + def publish_req( + self, + datasource_item, + filename, + file_contents, + connection_credentials=None, + connections=None, + ): xml_request = self._generate_xml(datasource_item, connection_credentials, connections) parts = { @@ -193,6 +200,66 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) +class DQWRequest(object): + def add_req(self, dqw_item): + xml_request = ET.Element("tsRequest") + dqw_element = ET.SubElement(xml_request, "dataQualityWarning") + + dqw_element.attrib["isActive"] = str(dqw_item.active).lower() + dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() + + dqw_element.attrib["type"] = dqw_item.warning_type + + if dqw_item.message: + dqw_element.attrib["message"] = str(dqw_item.message) + + return ET.tostring(xml_request) + + def update_req(self, database_item): + xml_request = ET.Element("tsRequest") + dqw_element = ET.SubElement(xml_request, "dataQualityWarning") + + dqw_element.attrib["isActive"] = str(dqw_item.active).lower() + dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() + + dqw_element.attrib["type"] = dqw_item.warning_type + + if dqw_item.message: + dqw_element.attrib["message"] = str(dqw_item.message) + + return ET.tostring(xml_request) + + +class DQWRequest(object): + def add_req(self, dqw_item): + xml_request = ET.Element("tsRequest") + dqw_element = ET.SubElement(xml_request, "dataQualityWarning") + + dqw_element.attrib["isActive"] = str(dqw_item.active).lower() + dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() + + dqw_element.attrib["type"] = dqw_item.warning_type + + if dqw_item.message: + dqw_element.attrib["message"] = str(dqw_item.message) + + return ET.tostring(xml_request) + + def update_req(self, database_item): + xml_request = ET.Element("tsRequest") + dqw_element = ET.SubElement(xml_request, "dataQualityWarning") + + dqw_element.attrib["isActive"] = str(dqw_item.active).lower() + dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() + + dqw_element.attrib["type"] = dqw_item.warning_type + + if dqw_item.message: + dqw_element.attrib["message"] = str(dqw_item.message) + + return ET.tostring(xml_request) + + class FavoriteRequest(object): def _add_to_req(self, id_, target_type, label): """ @@ -223,7 +290,10 @@ def add_workbook_req(self, id_, name): class FileuploadRequest(object): def chunk_req(self, chunk): - parts = {"request_payload": ("", "", "text/xml"), "tableau_file": ("file", chunk, "application/octet-stream")} + parts = { + "request_payload": ("", "", "text/xml"), + "tableau_file": ("file", chunk, "application/octet-stream"), + } return _add_multipart(parts) @@ -724,7 +794,13 @@ def add_req(self, user_item): class WorkbookRequest(object): - def _generate_xml(self, workbook_item, connection_credentials=None, connections=None, hidden_views=None): + def _generate_xml( + self, + workbook_item, + connection_credentials=None, + connections=None, + hidden_views=None, + ): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") workbook_element.attrib["name"] = workbook_item.name @@ -778,7 +854,13 @@ def update_req(self, workbook_item): return ET.tostring(xml_request) def publish_req( - self, workbook_item, filename, file_contents, connection_credentials=None, connections=None, hidden_views=None + self, + workbook_item, + filename, + file_contents, + connection_credentials=None, + connections=None, + hidden_views=None, ): xml_request = self._generate_xml( workbook_item, @@ -793,7 +875,13 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, workbook_item, connection_credentials=None, connections=None, hidden_views=None): + def publish_req_chunked( + self, + workbook_item, + connection_credentials=None, + connections=None, + hidden_views=None, + ): xml_request = self._generate_xml( workbook_item, connection_credentials=connection_credentials, @@ -932,6 +1020,7 @@ class RequestFactory(object): DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() + DQW = DQWRequest() Empty = EmptyRequest() Favorite = FavoriteRequest() Fileupload = FileuploadRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b45098e8a..057c98877 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -25,7 +25,10 @@ Favorites, DataAlerts, ) -from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError +from .endpoint.exceptions import ( + EndpointUnavailableError, + ServerInfoEndpointNotFoundError, +) import requests @@ -34,7 +37,13 @@ except ImportError: from distutils.version import LooseVersion as Version -_PRODUCT_TO_REST_VERSION = {"10.0": "2.3", "9.3": "2.2", "9.2": "2.1", "9.1": "2.0", "9.0": "2.0"} +_PRODUCT_TO_REST_VERSION = { + "10.0": "2.3", + "9.3": "2.2", + "9.2": "2.1", + "9.1": "2.0", + "9.0": "2.0", +} class Server(object): diff --git a/test/assets/dqw_by_content_type.xml b/test/assets/dqw_by_content_type.xml new file mode 100644 index 000000000..c65deb6d9 --- /dev/null +++ b/test/assets/dqw_by_content_type.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/test_database.py b/test/test_database.py index fb9ffbd86..4de623dae 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -11,12 +11,12 @@ GET_XML = 'database_get.xml' POPULATE_PERMISSIONS_XML = 'database_populate_permissions.xml' UPDATE_XML = 'database_update.xml' +GET_DQW_BY_CONTENT = "dqw_by_content_type.xml" class DatabaseTests(unittest.TestCase): def setUp(self): self.server = TSC.Server('http://test') - # Fake signin self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' @@ -81,6 +81,27 @@ def test_populate_permissions(self): TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, }) + def test_populate_data_quality_warning(self): + with open(asset(GET_DQW_BY_CONTENT), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.server.databases._data_quality_warnings.baseurl + '/94441d26-9a52-4a42-b0fb-3f94792d1aac', text=response_xml) + single_database = TSC.DatabaseItem('test') + single_database._id = '94441d26-9a52-4a42-b0fb-3f94792d1aac' + + self.server.databases.populate_dqw(single_database) + dqws = single_database.dqws + first_dqw = dqws.pop() + self.assertEqual(first_dqw.id, "c2e0e406-84fb-4f4e-9998-f20dd9306710") + self.assertEqual(first_dqw.warning_type, "WARNING") + self.assertEqual(first_dqw.message, "Hello, World!") + self.assertEqual(first_dqw.owner_id, "eddc8c5f-6af0-40be-b6b0-2c790290a43f") + self.assertEqual(first_dqw.active, True) + self.assertEqual(first_dqw.severe, True) + self.assertEqual(str(first_dqw.created_at), "2021-04-09 18:39:54+00:00") + self.assertEqual(str(first_dqw.updated_at), "2021-04-09 18:39:54+00:00") + + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) From 1c694ebb3da287f66187f0b3bc04d14384a348b6 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Wed, 5 May 2021 13:27:00 -0500 Subject: [PATCH 234/567] Correct Data Alert repr (#821) * Correct Data Alert repr * Fix repr on subscription item Co-authored-by: Jordan Woods --- tableauserverclient/models/data_alert_item.py | 4 ++-- tableauserverclient/models/subscription_item.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index d719469b0..a4d11ca5e 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -36,8 +36,8 @@ def __init__(self): self._recipients = None def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index c5ac10168..bc431ed77 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -20,7 +20,7 @@ def __init__(self, subject, schedule_id, user_id, target): def __repr__(self): if self.id is not None: - return " Date: Mon, 10 May 2021 14:48:36 -0700 Subject: [PATCH 235/567] fix default permissions mode (#844) Change from False to True, since that's the real default --- tableauserverclient/models/site_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 7fb1d116e..f3e918ae5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -49,7 +49,7 @@ def __init__( tier_viewer_capacity=None, data_alerts_enabled=True, commenting_mentions_enabled=True, - catalog_obfuscation_enabled=False, + catalog_obfuscation_enabled=True, flow_auto_save_enabled=True, web_extraction_enabled=True, metrics_content_type_enabled=True, From 54d7e594214b05cac917802f2a2516c5e65e6e04 Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 14 May 2021 13:02:32 -0400 Subject: [PATCH 236/567] Fixes revision limit field not propagating to the request (#847) --- tableauserverclient/models/site_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index f3e918ae5..ab0211414 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -31,7 +31,7 @@ def __init__( disable_subscriptions=False, subscribe_others_enabled=True, revision_history_enabled=False, - revision_limit=None, + revision_limit=25, data_acceleration_mode=None, flows_enabled=True, cataloging_enabled=True, @@ -76,13 +76,13 @@ def __init__( self._state = None self._status_reason = None self._storage = None - self._revision_limit = None self.user_quota = user_quota self.storage_quota = storage_quota self.content_url = content_url self.disable_subscriptions = disable_subscriptions self.name = name self.revision_history_enabled = revision_history_enabled + self.revision_limit = revision_limit self.subscribe_others_enabled = subscribe_others_enabled self.admin_mode = admin_mode self.data_acceleration_mode = data_acceleration_mode From 9541ccdd5c50d4ca5c118fc4b7edf279138e3d7f Mon Sep 17 00:00:00 2001 From: Udit Chaudhary Date: Sun, 23 May 2021 19:50:33 +0530 Subject: [PATCH 237/567] feat: accept parameters for metadata api This will allow request parameters like timeout to be set by user. Because the metadata api use post_request endpoint, that endpoint will also support adding request parameters. This feature was already present in the GET endpoint --- tableauserverclient/server/endpoint/endpoint.py | 4 ++-- tableauserverclient/server/endpoint/metadata_endpoint.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index dc504242a..c3a0914a7 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -106,11 +106,11 @@ def put_request(self, url, xml_request=None, content_type='text/xml'): auth_token=self.parent_srv.auth_token, content_type=content_type) - def post_request(self, url, xml_request, content_type='text/xml'): + def post_request(self, url, xml_request, content_type='text/xml', parameters=None): return self._make_request(self.parent_srv.session.post, url, content=xml_request, auth_token=self.parent_srv.auth_token, - content_type=content_type) + content_type=content_type, parameters=parameters) def api(version): diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index ac111d6ef..df29eaeb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -55,7 +55,7 @@ def control_baseurl(self): return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) @api("3.5") - def query(self, query, variables=None, abort_on_error=False): + def query(self, query, variables=None, abort_on_error=False, parameters=None): logger.info('Querying Metadata API') url = self.baseurl @@ -65,7 +65,7 @@ def query(self, query, variables=None, abort_on_error=False): raise InvalidGraphQLQuery('Must provide a string') # Setting content type because post_reuqest defaults to text/xml - server_response = self.post_request(url, graphql_query, content_type='text/json') + server_response = self.post_request(url, graphql_query, content_type='text/json', parameters=parameters) results = server_response.json() if abort_on_error and results.get('errors', None): From 0c4fd4a8201b15300dda8441f280b1d10c6d800d Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 May 2021 13:15:49 -0700 Subject: [PATCH 238/567] Create slack.yml Created a new action from https://github.com/marketplace/actions/send-message-to-slack --- .github/workflows/slack.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/slack.yml diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml new file mode 100644 index 000000000..7d9052bfd --- /dev/null +++ b/.github/workflows/slack.yml @@ -0,0 +1,18 @@ +name: 💬 Send Message to Slack 🚀 + +on: [push, pull_request, issues] + +jobs: + slack-notifications: + runs-on: ubuntu-20.04 + name: Sends a message to Slack when a push, a pull request or an issue is made + steps: + - name: Send message to Slack API + uses: archive/github-actions-slack@v2.0.1 + id: notify + with: + slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + slack-channel: C019HCX84L9 + slack-text: Hello! Event "${{ github.event_name }}" in "${{ github.repository }}" 🤓 + - name: Result from "Send Message" + run: echo "The result was ${{ steps.notify.outputs.slack-result }}" From ffefd80d7985b4881c8dc9f9c6fdb44e952f67a6 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 May 2021 13:34:07 -0700 Subject: [PATCH 239/567] whitespace change to re-try PR --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e2c30704a..c08371a2f 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,4 @@ This repository contains Python source code and sample files. Python versions 3. For more information on installing and using TSC, see the documentation: + From 5d333f597828819372f5cfdf8104c70476e39bbf Mon Sep 17 00:00:00 2001 From: annematronic <73560907+annematronic@users.noreply.github.com> Date: Tue, 25 May 2021 06:37:48 -0700 Subject: [PATCH 240/567] 841 enhancement rename datasource (#843) * Add support for rename datasource * remove erroneous sys.path line * remove sys import --- tableauserverclient/server/request_factory.py | 2 + test/test_datasource.py | 48 ++++++++++--------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c03a4fadc..4cbea1443 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -158,6 +158,8 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection def update_req(self, datasource_item): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement diff --git a/test/test_datasource.py b/test/test_datasource.py index 1156069be..e221f0c88 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -11,6 +11,7 @@ from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset + ADD_TAGS_XML = 'datasource_add_tags.xml' GET_XML = 'datasource_get.xml' GET_EMPTY_XML = 'datasource_get_empty.xml' @@ -106,25 +107,28 @@ def test_update(self): response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'Sample datasource') single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + single_datasource._content_url = 'Sampledatasource' single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' single_datasource.certified = True single_datasource.certification_note = "Warning, here be dragons." - single_datasource = self.server.datasources.update(single_datasource) + updated_datasource = self.server.datasources.update(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_datasource.project_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_datasource.owner_id) - self.assertEqual(True, single_datasource.certified) - self.assertEqual("Warning, here be dragons.", single_datasource.certification_note) + self.assertEqual(updated_datasource.id, single_datasource.id) + self.assertEqual(updated_datasource.name, single_datasource.name) + self.assertEqual(updated_datasource.content_url, single_datasource.content_url) + self.assertEqual(updated_datasource.project_id, single_datasource.project_id) + self.assertEqual(updated_datasource.owner_id, single_datasource.owner_id) + self.assertEqual(updated_datasource.certified, single_datasource.certified) + self.assertEqual(updated_datasource.certification_note, single_datasource.certification_note) def test_update_copy_fields(self): with open(asset(UPDATE_XML), 'rb') as f: response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' single_datasource._project_name = 'Tester' updated_datasource = self.server.datasources.update(single_datasource) @@ -152,7 +156,7 @@ def test_populate_connections(self): response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) with requests_mock.mock() as m: m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) - single_datasource = TSC.DatasourceItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.datasources.populate_connections(single_datasource) @@ -180,7 +184,7 @@ def test_update_connection(self): m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', text=response_xml) - single_datasource = TSC.DatasourceItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') + single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74') single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' self.server.datasources.populate_connections(single_datasource) @@ -201,7 +205,7 @@ def test_populate_permissions(self): response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_datasource = TSC.DatasourceItem('test') + single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' self.server.datasources.populate_permissions(single_datasource) @@ -226,7 +230,7 @@ def test_publish(self): response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') publish_mode = self.server.PublishMode.CreateNew new_datasource = self.server.datasources.publish(new_datasource, @@ -247,7 +251,7 @@ def test_publish_a_non_packaged_file_object(self): response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') publish_mode = self.server.PublishMode.CreateNew with open(asset('SampleDS.tds'), 'rb') as file_object: @@ -269,7 +273,7 @@ def test_publish_a_packaged_file_object(self): response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') publish_mode = self.server.PublishMode.CreateNew # Create a dummy tdsx file in memory @@ -299,7 +303,7 @@ def test_publish_async(self): response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: m.post(baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') publish_mode = self.server.PublishMode.CreateNew new_job = self.server.datasources.publish(new_datasource, @@ -380,40 +384,40 @@ def test_download_extract_only(self): os.remove(file_path) def test_update_missing_id(self): - single_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + single_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') self.assertRaises(TSC.MissingRequiredFieldError, self.server.datasources.update, single_datasource) def test_publish_missing_path(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') self.assertRaises(IOError, self.server.datasources.publish, new_datasource, '', self.server.PublishMode.CreateNew) def test_publish_missing_mode(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleDS.tds'), None) def test_publish_invalid_file_type(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset('SampleWB.twbx'), self.server.PublishMode.Append) def test_publish_hyper_file_object_raises_exception(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') with open(asset('World Indicators.hyper')) as file_object: self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append) def test_publish_tde_file_object_raises_exception(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') tds_asset = asset(os.path.join('Data', 'Tableau Samples', 'World Indicators.tde')) with open(tds_asset) as file_object: self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append) def test_publish_file_object_of_unknown_type_raises_exception(self): - new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') with BytesIO() as file_object: file_object.write(bytes.fromhex('89504E470D0A1A0A')) From 1a25f5cfde99c6919d6e8de3d3c6599f1352586f Mon Sep 17 00:00:00 2001 From: Udit Chaudhary Date: Thu, 24 Jun 2021 20:17:16 +0530 Subject: [PATCH 241/567] refactor: corrected line length of files --- tableauserverclient/server/endpoint/endpoint.py | 3 +-- tableauserverclient/server/endpoint/metadata_endpoint.py | 4 ++-- tableauserverclient/server/endpoint/permissions_endpoint.py | 2 +- tableauserverclient/server/endpoint/server_info_endpoint.py | 2 +- tableauserverclient/server/request_options.py | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 4c5006b33..f7d88b0e6 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -118,7 +118,6 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request=None, content_type="text/xml"): return self._make_request( self.parent_srv.session.put, @@ -135,7 +134,7 @@ def post_request(self, url, xml_request, content_type="text/xml", parameters=Non content=xml_request, auth_token=self.parent_srv.auth_token, content_type=content_type, - parameters=parameters + parameters=parameters, ) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index d88f3788b..4e32d26f0 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -58,7 +58,7 @@ def control_baseurl(self): @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): - logger.info('Querying Metadata API') + logger.info("Querying Metadata API") url = self.baseurl @@ -68,7 +68,7 @@ def query(self, query, variables=None, abort_on_error=False, parameters=None): raise InvalidGraphQLQuery("Must provide a string") # Setting content type because post_reuqest defaults to text/xml - server_response = self.post_request(url, graphql_query, content_type='text/json', parameters=parameters) + server_response = self.post_request(url, graphql_query, content_type="text/json", parameters=parameters) results = server_response.json() if abort_on_error and results.get("errors", None): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 7035837f4..5013a0bef 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -44,7 +44,7 @@ def delete(self, resource, rules): for rule in rules: for capability, mode in rule.capabilities.items(): - " /permissions/groups/group-id/capability-name/capability-mode" + "/permissions/groups/group-id/capability-name/capability-mode" url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( self.owner_baseurl(), resource.id, diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 8776477d3..ca3715fca 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -17,7 +17,7 @@ def baseurl(self): @api(version="2.4") def get(self): - """ Retrieve the server info for the server. This is an unauthenticated call """ + """Retrieve the server info for the server. This is an unauthenticated call""" try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 23d10b3d6..4ebf1e4d6 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -98,7 +98,7 @@ def get_query_params(self): class _FilterOptionsBase(RequestOptionsBase): - """ Provide a basic implementation of adding view filters to the url """ + """Provide a basic implementation of adding view filters to the url""" def __init__(self): self.view_filters = [] From 65c9df2d4c325c9f6d45702a756b9cf5c165a2d5 Mon Sep 17 00:00:00 2001 From: Ovini Nanayakkara <44311587+ovinis@users.noreply.github.com> Date: Mon, 28 Jun 2021 13:29:42 -0700 Subject: [PATCH 242/567] fixed Issue# 840- Missing content permission field LockedToProjectWithoutNested (#856) Co-authored-by: Ovini Nanayakkara --- tableauserverclient/_version.py | 8 ++++---- tableauserverclient/models/project_item.py | 1 + test/assets/project_content_permission.xml | 4 ++++ test/test_database.py | 4 ++-- test/test_project.py | 18 ++++++++++++++++++ 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 test/assets/project_content_permission.xml diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 5e73890bd..1737a980a 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -120,7 +120,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return { - "version": dirname[len(parentdir_prefix) :], + "version": dirname[len(parentdir_prefix):], "full-revisionid": None, "dirty": False, "error": None, @@ -187,7 +187,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -204,7 +204,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] if verbose: print("picking %s" % r) return { @@ -304,7 +304,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): tag_prefix, ) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index bed6def6e..3a7d01143 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -10,6 +10,7 @@ class ProjectItem(object): class ContentPermissions: LockedToProject = "LockedToProject" ManagedByOwner = "ManagedByOwner" + LockedToProjectWithoutNested = "LockedToProjectWithoutNested" def __init__(self, name, description=None, content_permissions=None, parent_id=None): self._content_permissions = None diff --git a/test/assets/project_content_permission.xml b/test/assets/project_content_permission.xml new file mode 100644 index 000000000..18341e2ac --- /dev/null +++ b/test/assets/project_content_permission.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_database.py b/test/test_database.py index 4de623dae..e7c6a6fb6 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -85,7 +85,8 @@ def test_populate_data_quality_warning(self): with open(asset(GET_DQW_BY_CONTENT), 'rb') as f: response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: - m.get(self.server.databases._data_quality_warnings.baseurl + '/94441d26-9a52-4a42-b0fb-3f94792d1aac', text=response_xml) + m.get(self.server.databases._data_quality_warnings.baseurl + '/94441d26-9a52-4a42-b0fb-3f94792d1aac', + text=response_xml) single_database = TSC.DatabaseItem('test') single_database._id = '94441d26-9a52-4a42-b0fb-3f94792d1aac' @@ -101,7 +102,6 @@ def test_populate_data_quality_warning(self): self.assertEqual(str(first_dqw.created_at), "2021-04-09 18:39:54+00:00") self.assertEqual(str(first_dqw.updated_at), "2021-04-09 18:39:54+00:00") - def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) diff --git a/test/test_project.py b/test/test_project.py index 045f0a43e..be43b063e 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -9,6 +9,7 @@ GET_XML = asset('project_get.xml') UPDATE_XML = asset('project_update.xml') +SET_CONTENT_PERMISSIONS_XML = asset('project_content_permission.xml') CREATE_XML = asset('project_create.xml') POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml' POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml' @@ -83,6 +84,23 @@ def test_update(self): self.assertEqual('LockedToProject', single_project.content_permissions) self.assertEqual('9a8f2265-70f3-4494-96c5-e5949d7a1120', single_project.parent_id) + def test_content_permission_locked_to_project_without_nested(self): + with open(SET_CONTENT_PERMISSIONS_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.put(self.baseurl + '/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', text=response_xml) + project_item = TSC.ProjectItem(name='Test Project Permissions', + content_permissions='LockedToProjectWithoutNested', + description='Project created for testing', + parent_id='7687bc43-a543-42f3-b86f-80caed03a813') + project_item._id = 'cb3759e5-da4a-4ade-b916-7e2b4ea7ec86' + project_item = self.server.projects.update(project_item) + self.assertEqual('cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', project_item.id) + self.assertEqual('Test Project Permissions', project_item.name) + self.assertEqual('Project created for testing', project_item.description) + self.assertEqual('LockedToProjectWithoutNested', project_item.content_permissions) + self.assertEqual('7687bc43-a543-42f3-b86f-80caed03a813', project_item.parent_id) + def test_update_datasource_default_permission(self): response_xml = read_xml_asset(UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML) with requests_mock.mock() as m: From 770d48a6767846e18cb35a7fa3151e372eebfa32 Mon Sep 17 00:00:00 2001 From: Ovini Nanayakkara <44311587+ovinis@users.noreply.github.com> Date: Fri, 16 Jul 2021 12:31:03 -0400 Subject: [PATCH 243/567] Updated Changelog and contributors for the new release (#863) * Updated Changelog and contributors for the new release * updated Changelog to reflect Issue 844 accurately Co-authored-by: Ovini Nanayakkara --- CHANGELOG.md | 10 ++++++++++ CONTRIBUTORS.md | 2 ++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a44b251..c4c9197f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.16.0 (15 July 2021) +* Documentation updates (#800, #818, #839, #842) +* Fixed data alert repr in subscription item (#821) +* Added support for Data Quality Warning (#836) +* Added support for renaming datasources (#843) +* Improved Datasource tests (#843) +* Updated catalog obfuscation field (#844) +* Fixed revision limit field in site_item.py file (#847) +* Added the Missing content permission field- LockedToProjectWithoutNested (#856) + ## 0.15.0 (16 Feb 2021) * Added support for python version 3.9 (#744) * Added support for 'Get View by ID' (#750) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2a19b1317..74b20d93d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,7 @@ The following people have contributed to this project to make it possible, and w * [Terrence Jones](https://github.com/tjones-commits) * [John Vandenberg](https://github.com/jayvdb) * [Lee Boynton](https://github.com/lboynton) +* [annematronic](https://github.com/annematronic) ## Core Team @@ -57,3 +58,4 @@ The following people have contributed to this project to make it possible, and w * [Jac Fitzgerald](https://github.com/jacalata) * [Dan Zucker](https://github.com/dzucker-tab) * [Brian Cantoni](https://github.com/bcantoni) +* [Ovini Nanayakkara](https://github.com/ovinis) From 13614e7ced59b8da5d0f2a114b9c59d2a9921deb Mon Sep 17 00:00:00 2001 From: Chris Shin Date: Fri, 23 Jul 2021 09:51:00 -0700 Subject: [PATCH 244/567] Update publish.sh to use python3 (#866) --- publish.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/publish.sh b/publish.sh index 99a3115ec..02812c1c3 100755 --- a/publish.sh +++ b/publish.sh @@ -3,7 +3,6 @@ set -e rm -rf dist -python setup.py sdist -python setup.py bdist_wheel +python3 setup.py sdist python3 setup.py bdist_wheel twine upload dist/* From 7586ab1bed6c9bb84a4e3f29ed3b29db6681ba35 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 23 Aug 2021 16:18:14 -0700 Subject: [PATCH 245/567] Add handling for workbooks in personal spaces which will not have project ID or Name --- tableauserverclient/models/workbook_item.py | 5 ++++ test/assets/workbook_get_by_id_personal.xml | 13 +++++++++++ test/test_workbook.py | 26 +++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 test/assets/workbook_get_by_id_personal.xml diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 14ca8f33b..9c7e2022e 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -10,6 +10,7 @@ from .permissions_item import PermissionsRule from ..datetime_helpers import parse_datetime import copy +import uuid class WorkbookItem(object): @@ -275,6 +276,10 @@ def from_response(cls, resp, ns): data_acceleration_config, ) = cls._parse_element(workbook_xml, ns) + # workaround for Personal Space workbooks which won't have a project + if not project_id: + project_id = uuid.uuid4() + workbook_item = cls(project_id) workbook_item._set_values( id, diff --git a/test/assets/workbook_get_by_id_personal.xml b/test/assets/workbook_get_by_id_personal.xml new file mode 100644 index 000000000..90cc65e73 --- /dev/null +++ b/test/assets/workbook_get_by_id_personal.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index fc1344b9e..1a6714d19 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -20,6 +20,7 @@ ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_add_tags.xml') GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') +GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id_personal.xml') GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_invalid_date.xml') GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') @@ -128,6 +129,31 @@ def test_get_by_id(self): self.assertEqual('ENDANGERED SAFARI', single_workbook.views[0].name) self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', single_workbook.views[0].content_url) + def test_get_by_id_personal(self): + # workbooks in personal space don't have project_id or project_name + with open(GET_BY_ID_XML_PERSONAL, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d43', text=response_xml) + single_workbook = self.server.workbooks.get_by_id('3cc6cd06-89ce-4fdc-b935-5294135d6d43') + + self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d43', single_workbook.id) + self.assertEqual('SafariSample', single_workbook.name) + self.assertEqual('SafariSample', single_workbook.content_url) + self.assertEqual('http://tableauserver/#/workbooks/2/views', single_workbook.webpage_url) + self.assertEqual(False, single_workbook.show_tabs) + self.assertEqual(26, single_workbook.size) + self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) + self.assertEqual('description for SafariSample', single_workbook.description) + self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) + #self.assertIsNone(single_workbook.project_id) + #self.assertIsNone(single_workbook.project_name) + self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_workbook.owner_id) + self.assertEqual(set(['Safari', 'Sample']), single_workbook.tags) + self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_workbook.views[0].id) + self.assertEqual('ENDANGERED SAFARI', single_workbook.views[0].name) + self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', single_workbook.views[0].content_url) + def test_get_by_id_missing_id(self): self.assertRaises(ValueError, self.server.workbooks.get_by_id, '') From 1903d3270f3e99c1c86084fe359ec6d6fa494ef4 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 23 Aug 2021 16:33:00 -0700 Subject: [PATCH 246/567] Improve tests to show that project_id should be set to something, but project_name is expected to not --- test/test_workbook.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6714d19..d3a3b59b4 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -146,8 +146,8 @@ def test_get_by_id_personal(self): self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) self.assertEqual('description for SafariSample', single_workbook.description) self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) - #self.assertIsNone(single_workbook.project_id) - #self.assertIsNone(single_workbook.project_name) + self.assertTrue(single_workbook.project_id) + self.assertIsNone(single_workbook.project_name) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_workbook.owner_id) self.assertEqual(set(['Safari', 'Sample']), single_workbook.tags) self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_workbook.views[0].id) From 0845b7b148eb9aab427549bdd73c92f4abdee8e2 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 1 Sep 2021 14:22:53 -0700 Subject: [PATCH 247/567] Upgrade to newer Slack action provider --- .github/workflows/slack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 7d9052bfd..c3b17e8c4 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -8,7 +8,7 @@ jobs: name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API - uses: archive/github-actions-slack@v2.0.1 + uses: archive/github-actions-slack@v2.2.2 id: notify with: slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} From a910cbbd2274bcc2444205e4223f948ba711d806 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 5 Mar 2021 14:06:35 -0800 Subject: [PATCH 248/567] Add issue template --- .github/ISSUE_TEMPLATE/bug_report.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..a199226df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Create a bug report or request for help +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Versions** +Details of your environment, including: + - Tableau Server version (or note if using Tableau Online) + - Python version + - TSC library version + +**To Reproduce** +Steps to reproduce the behavior. Please include a code snippet where possible. + +**Results** +What are the results or error messages received? + +**NOTE:** Be careful not to post user names, passwords, auth tokens or any other private or sensitive information. From 09917f10c096eb5acaf7bfeaf5de5c5eca130c91 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 May 2021 13:15:49 -0700 Subject: [PATCH 249/567] Create slack.yml Created a new action from https://github.com/marketplace/actions/send-message-to-slack --- .github/workflows/slack.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/slack.yml diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml new file mode 100644 index 000000000..7d9052bfd --- /dev/null +++ b/.github/workflows/slack.yml @@ -0,0 +1,18 @@ +name: 💬 Send Message to Slack 🚀 + +on: [push, pull_request, issues] + +jobs: + slack-notifications: + runs-on: ubuntu-20.04 + name: Sends a message to Slack when a push, a pull request or an issue is made + steps: + - name: Send message to Slack API + uses: archive/github-actions-slack@v2.0.1 + id: notify + with: + slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + slack-channel: C019HCX84L9 + slack-text: Hello! Event "${{ github.event_name }}" in "${{ github.repository }}" 🤓 + - name: Result from "Send Message" + run: echo "The result was ${{ steps.notify.outputs.slack-result }}" From 00a41fb38fb6cd6e8d57edac4103d82017c201f5 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 May 2021 13:34:07 -0700 Subject: [PATCH 250/567] whitespace change to re-try PR --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1aed88d61..a5445e052 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,4 @@ This repository contains Python source code and sample files. Python versions 3. For more information on installing and using TSC, see the documentation: + From 6ebf334dace5815a957e979d25d7c09a7cb0f597 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 1 Sep 2021 14:22:53 -0700 Subject: [PATCH 251/567] Upgrade to newer Slack action provider --- .github/workflows/slack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 7d9052bfd..c3b17e8c4 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -8,7 +8,7 @@ jobs: name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API - uses: archive/github-actions-slack@v2.0.1 + uses: archive/github-actions-slack@v2.2.2 id: notify with: slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} From 1ea39c2e77b8522fb60362ba3ce81eb195995a84 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 15 Sep 2021 12:45:59 -0700 Subject: [PATCH 252/567] Add Mac and Win to PR testing pipeline Also add support for Pythong 3.10 RC --- .github/workflows/run-tests.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 45b9548c1..9a51ac7a9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,20 +1,21 @@ -name: Python package +name: Python tests on: [push] jobs: build: - - runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} From b67a80426d4a9143981ecd530f6b3e6e3b0313a5 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Wed, 22 Sep 2021 17:18:07 -0700 Subject: [PATCH 253/567] Revert slack.yml to unblock open PRs I don't have the time to figure out a fix for the moment, this might be the fastest way --- .github/workflows/slack.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/slack.yml diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml deleted file mode 100644 index c3b17e8c4..000000000 --- a/.github/workflows/slack.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: 💬 Send Message to Slack 🚀 - -on: [push, pull_request, issues] - -jobs: - slack-notifications: - runs-on: ubuntu-20.04 - name: Sends a message to Slack when a push, a pull request or an issue is made - steps: - - name: Send message to Slack API - uses: archive/github-actions-slack@v2.2.2 - id: notify - with: - slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} - slack-channel: C019HCX84L9 - slack-text: Hello! Event "${{ github.event_name }}" in "${{ github.repository }}" 🤓 - - name: Result from "Send Message" - run: echo "The result was ${{ steps.notify.outputs.slack-result }}" From cd3668541d8c0f071d08f26f207aea7701e6b4dc Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 23 Sep 2021 11:14:53 +0200 Subject: [PATCH 254/567] Extend `publish_datasource.py` sample to allow specifying a project name (#888) I don't have access to the `Default` project on my Tableau server. Still I want to be able to run this sample... --- samples/publish_datasource.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index fa0fe2a95..9c0099ac6 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -35,6 +35,7 @@ def main(): parser.add_argument('--filepath', '-f', required=True, help='filepath to the datasource to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + parser.add_argument('--project', help='Project within which to publish the datasource') parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') parser.add_argument('--conn-username', help='connection username') parser.add_argument('--conn-password', help='connection password') @@ -55,9 +56,22 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Create a new datasource item to publish - empty project_id field - # will default the publish to the site's default project - new_datasource = TSC.DatasourceItem(project_id="") + # Empty project_id field will default the publish to the site's default project + project_id = "" + + # Retrieve the project id, if a project name was passed + if args.project is not None: + req_options = TSC.RequestOptions() + req_options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + args.project)) + projects = list(TSC.Pager(server.projects, req_options)) + if len(projects) > 1: + raise ValueError("The project name is not unique") + project_id = projects[0].id + + # Create a new datasource item to publish + new_datasource = TSC.DatasourceItem(project_id=project_id) # Create a connection_credentials item if connection details are provided new_conn_creds = None From df481ffc7da27a7d0d2fe06564de8b2447174fe3 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sat, 18 Sep 2021 11:21:53 +0200 Subject: [PATCH 255/567] Tests: Verify `datasources.refresh` to return the scheduled job --- test/test_datasource.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index e221f0c88..42d1dfade 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -333,7 +333,13 @@ def test_refresh_id(self): with requests_mock.mock() as m: m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', status_code=202, text=response_xml) - self.server.datasources.refresh('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + new_job = self.server.datasources.refresh('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual('RefreshExtract', new_job.type) + self.assertEqual(None, new_job.progress) + self.assertEqual('2020-03-05T22:05:32Z', format_datetime(new_job.created_at)) + self.assertEqual(-1, new_job.finish_code) def test_refresh_object(self): self.server.version = '2.8' @@ -344,7 +350,10 @@ def test_refresh_object(self): with requests_mock.mock() as m: m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', status_code=202, text=response_xml) - self.server.datasources.refresh(datasource) + new_job = self.server.datasources.refresh(datasource) + + # We only check the `id`; remaining fields are already tested in `test_refresh_id` + self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) def test_delete(self): with requests_mock.mock() as m: From b0e9abf5a688c6dca991b2b0bfc19d6dc8711bb9 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sat, 18 Sep 2021 16:05:33 +0200 Subject: [PATCH 256/567] Remove dead code from `datasources.publish` --- tableauserverclient/server/endpoint/datasources_endpoint.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index ccdbfa0d1..7b80c2b2b 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -282,10 +282,6 @@ def publish( new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource - server_response = self.post_request(url, xml_request, content_type) - new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) - return new_datasource @api(version="2.0") def populate_permissions(self, item): From 1c802ee3bcb0505d861b9828472bfac48cc50214 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sat, 18 Sep 2021 22:56:28 +0200 Subject: [PATCH 257/567] Use correct JSON Mimetype The official MIME type for JSON is `application/json`, not `text/json`. --- tableauserverclient/server/endpoint/metadata_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 368a92a97..421c80f95 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -67,7 +67,7 @@ def query(self, query, variables=None, abort_on_error=False): raise InvalidGraphQLQuery("Must provide a string") # Setting content type because post_reuqest defaults to text/xml - server_response = self.post_request(url, graphql_query, content_type="text/json") + server_response = self.post_request(url, graphql_query, content_type="application/json") results = server_response.json() if abort_on_error and results.get("errors", None): @@ -112,7 +112,7 @@ def paginated_query(self, query, variables=None, abort_on_error=False): paginated_results = results_dict["pages"] # get first page - server_response = self.post_request(url, graphql_query, content_type="text/json") + server_response = self.post_request(url, graphql_query, content_type="application/json") results = server_response.json() if abort_on_error and results.get("errors", None): @@ -129,7 +129,7 @@ def paginated_query(self, query, variables=None, abort_on_error=False): # make the call logger.debug("Calling Token: " + cursor) graphql_query = json.dumps({"query": query, "variables": variables}) - server_response = self.post_request(url, graphql_query, content_type="text/json") + server_response = self.post_request(url, graphql_query, content_type="application/json") results = server_response.json() # verify response if abort_on_error and results.get("errors", None): From 74bec027ca1c3975b73ad88415643c761da3f16b Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sat, 18 Sep 2021 14:30:19 +0200 Subject: [PATCH 258/567] Unify arguments of sample scripts I am pretty new to TSC, and wanted to run some sample scripts to get an understanding of the library. Doing so, I realized that every sample had a slightly different command line, even for common arguments: * Some expected `site`, some `site-id`, some were lacking site-support completely (and thereby unusable for Tableau Online) * Some had a short option `-i`, some had the short option `-S` for the site name * Some expected password-based authentication, some expected personal access tokens This commit fixes all those inconsistencies, so that users don't have to re-learn the command line options for each individual script. --- samples/add_default_permission.py | 24 +++++++++++------------- samples/create_group.py | 17 +++++++++++------ samples/create_project.py | 23 ++++++++++------------- samples/create_schedules.py | 17 +++++++++++------ samples/download_view_image.py | 29 ++++++++++++----------------- samples/explore_datasource.py | 20 +++++++++++--------- samples/explore_webhooks.py | 30 +++++++++++------------------- samples/explore_workbook.py | 28 ++++++++++++++-------------- samples/export.py | 19 ++++++++----------- samples/export_wb.py | 21 +++++++++------------ samples/filter_sort_groups.py | 25 +++++++++++-------------- samples/filter_sort_projects.py | 23 ++++++++++------------- samples/initialize_server.py | 24 +++++++++++++----------- samples/kill_all_jobs.py | 21 +++++++++------------ samples/list.py | 21 +++++++++------------ samples/login.py | 19 ++++++++++--------- samples/move_workbook_projects.py | 20 +++++++++++--------- samples/move_workbook_sites.py | 18 +++++++++++------- samples/pagination_sample.py | 21 +++++++++++---------- samples/publish_datasource.py | 6 ++++-- samples/publish_workbook.py | 23 ++++++++++++----------- samples/query_permissions.py | 22 +++++++++------------- samples/refresh.py | 21 ++++++++------------- samples/refresh_tasks.py | 23 +++++++++-------------- samples/set_http_options.py | 14 +++++++++----- samples/set_refresh_schedule.py | 17 ++++++++--------- samples/update_connection.py | 21 ++++++++------------- 27 files changed, 270 insertions(+), 297 deletions(-) diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 63c38f53d..77ad58a11 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -10,7 +10,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -18,27 +17,26 @@ def main(): parser = argparse.ArgumentParser(description='Add workbook default permissions for a given project.') - parser.add_argument('--server', '-s', required=True, help='Server address') - parser.add_argument('--username', '-u', required=True, help='Username to sign into server') - parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') - parser.add_argument('-p', default=None, help='Password to sign into server') - + # Common options; please keep those in sync across all samples + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # 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) + # Sign in to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): diff --git a/samples/create_group.py b/samples/create_group.py index 7f9dc1e96..4459eb96a 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -7,7 +7,6 @@ import argparse -import getpass import logging from datetime import time @@ -18,20 +17,26 @@ def main(): parser = argparse.ArgumentParser(description='Creates a sample user group.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - args = parser.parse_args() + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here - password = getpass.getpass("Password: ") + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): group = TSC.GroupItem('test') group = server.groups.create(group) diff --git a/samples/create_project.py b/samples/create_project.py index 0380cb8a0..b3b28c2dc 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -8,7 +8,6 @@ #### import argparse -import getpass import logging import sys @@ -27,28 +26,26 @@ def create_project(server, project_item): def main(): parser = argparse.ArgumentParser(description='Create new projects.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None, help='password') - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available server.use_server_version() diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c1bcb712f..3c2627bf6 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -7,7 +7,6 @@ import argparse -import getpass import logging from datetime import time @@ -18,20 +17,26 @@ def main(): parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - args = parser.parse_args() + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here - password = getpass.getpass("Password: ") + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Hourly Schedule # This schedule will run every 2 hours between 2:30AM and 11:00PM diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 07162eebf..17cc2000b 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -9,7 +9,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -18,34 +17,30 @@ def main(): parser = argparse.ArgumentParser(description='Download image of a specified view.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site-id', '-si', required=False, - help='content url for site the view is on') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--view-name', '-v', required=True, + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--view-name', '-vn', required=True, help='name of view to download an image of') parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned') parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') args = parser.parse_args() - password = getpass.getpass("Password: ") - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - site_id = args.site_id - if not site_id: - site_id = "" - tableau_auth = TSC.TableauAuth(args.username, password, site_id=site_id) - server = TSC.Server(args.server) - # The new endpoint was introduced in Version 2.5 - server.version = "2.5" - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of req_option = TSC.RequestOptions() diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index e740d60f1..a78345122 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -10,7 +10,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -19,25 +18,28 @@ def main(): parser = argparse.ArgumentParser(description='Explore datasource functions supported by the Server API.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--publish', '-p', metavar='FILEPATH', help='path to datasource to publish') - parser.add_argument('--download', '-d', metavar='FILEPATH', help='path to save downloaded datasource') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--publish', metavar='FILEPATH', help='path to datasource to publish') + parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded datasource') args = parser.parse_args() - password = getpass.getpass("Password: ") - # 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) - server = TSC.Server(args.server) - server.use_highest_version() + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Query projects for use when demonstrating publishing and updating all_projects, pagination_item = server.projects.get() diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index ab94f7195..50c677cba 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -10,7 +10,6 @@ #### import argparse -import getpass import logging import os.path @@ -20,35 +19,28 @@ def main(): parser = argparse.ArgumentParser(description='Explore webhook functions supported by the Server API.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None, help='password') - parser.add_argument('--create', '-c', help='create a webhook') - parser.add_argument('--delete', '-d', help='delete a webhook', action='store_true') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--create', help='create a webhook') + parser.add_argument('--delete', help='delete a webhook', action='store_true') args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p # 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) - print("Signing in to " + args.server + " [" + args.site + "] as " + args.username) - server = TSC.Server(args.server) - - # Set http options to disable verifying SSL - server.add_http_options({'verify': False}) - - server.use_server_version() - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Create webhook if create flag is set (-create, -c) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 88eebc1a3..8746db80e 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -10,7 +10,6 @@ #### import argparse -import getpass import logging import os.path @@ -20,33 +19,34 @@ def main(): parser = argparse.ArgumentParser(description='Explore workbook functions supported by the Server API.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--publish', '-p', metavar='FILEPATH', help='path to workbook to publish') - parser.add_argument('--download', '-d', metavar='FILEPATH', help='path to save downloaded workbook') - parser.add_argument('--preview-image', '-i', metavar='FILENAME', - help='filename (a .png file) to save the preview image') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--publish', metavar='FILEPATH', help='path to workbook to publish') + parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded workbook') + parser.add_argument('--preview-image', '-i', metavar='FILENAME', + help='filename (a .png file) to save the preview image') args = parser.parse_args() - password = getpass.getpass("Password: ") - # 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) - server = TSC.Server(args.server) - server.use_highest_version() - - overwrite_true = TSC.Server.PublishMode.Overwrite - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Publish workbook if publish flag is set (-publish, -p) + overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) diff --git a/samples/export.py b/samples/export.py index b8cd01140..2b6de57f9 100644 --- a/samples/export.py +++ b/samples/export.py @@ -6,7 +6,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -14,13 +13,16 @@ def main(): parser = argparse.ArgumentParser(description='Export a view as an image, PDF, or CSV') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', 'pdf')) @@ -36,16 +38,11 @@ def main(): args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password, args.site) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): views = filter(lambda x: x.id == args.resource_id, diff --git a/samples/export_wb.py b/samples/export_wb.py index 334d57c89..a9b4d60be 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -9,7 +9,6 @@ import argparse -import getpass import logging import tempfile import shutil @@ -52,23 +51,21 @@ def cleanup(tempdir): def main(): parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.') + # Common options; please keep those in sync across all samples 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', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') parser.add_argument('resource_id', help='LUID for the workbook') args = parser.parse_args() - if args.password is None: - password = getpass.getpass("Password: ") - else: - password = args.password - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) @@ -76,9 +73,9 @@ def main(): tempdir = tempfile.mkdtemp('tsc') logging.debug("Saving to tempdir: %s", tempdir) - tableau_auth = TSC.TableauAuth(args.username, password, args.site) - server = TSC.Server(args.server, use_server_version=True) try: + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): get_list = functools.partial(get_views_for_workbook, server) download = functools.partial(download_pdf, server, tempdir) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index f8123a29c..7f160f66d 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -7,7 +7,6 @@ import argparse -import getpass import logging import tableauserverclient as TSC @@ -25,30 +24,28 @@ def create_example_group(group_name='Example Group', server=None): def main(): parser = argparse.ArgumentParser(description='Filter and sort groups.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('-p', default=None) - args = parser.parse_args() + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Determine and use the highest api version for the server - server.use_server_version() - group_name = 'SALES NORTHWEST' # Try to create a group named "SALES NORTHWEST" create_example_group(group_name, server) diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 0c62614b0..e4f695fda 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -6,7 +6,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -26,28 +25,26 @@ def create_example_project(name='Example Project', content_permissions='LockedTo def main(): parser = argparse.ArgumentParser(description='Filter and sort projects.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available server.use_server_version() diff --git a/samples/initialize_server.py b/samples/initialize_server.py index a3e312ce9..a7dd552e1 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -5,7 +5,6 @@ #### import argparse -import getpass import glob import logging import tableauserverclient as TSC @@ -13,17 +12,21 @@ def main(): parser = argparse.ArgumentParser(description='Initialize a server with content.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--datasources-folder', '-df', required=True, help='folder containing datasources') - parser.add_argument('--workbooks-folder', '-wf', required=True, help='folder containing workbooks') - parser.add_argument('--site-id', '-sid', required=False, default='', help='site id of the site to use') - parser.add_argument('--project', '-p', required=False, default='Default', help='project to use') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - args = parser.parse_args() + # Options specific to this sample + parser.add_argument('--datasources-folder', '-df', required=True, help='folder containing datasources') + parser.add_argument('--workbooks-folder', '-wf', required=True, help='folder containing workbooks') + parser.add_argument('--project', required=False, default='Default', help='project to use') - password = getpass.getpass("Password: ") + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) @@ -32,9 +35,8 @@ def main(): ################################################################################ # Step 1: Sign in to server. ################################################################################ - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): ################################################################################ diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1aeb7298e..f9fa173e5 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -5,7 +5,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -13,27 +12,25 @@ def main(): parser = argparse.ArgumentParser(description='Cancel all of the running background jobs.') + # Common options; please keep those in sync across all samples 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', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - if args.password is None: - password = getpass.getpass("Password: ") - else: - password = args.password - # 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, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): req = TSC.RequestOptions() diff --git a/samples/list.py b/samples/list.py index 10e11ac04..8a6407e0d 100644 --- a/samples/list.py +++ b/samples/list.py @@ -5,7 +5,6 @@ #### import argparse -import getpass import logging import os import sys @@ -15,28 +14,26 @@ def main(): parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - 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('--site', '-S', help='site name') + parser.add_argument('--token-name', '-n', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - + # Options specific to this sample parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks']) args = parser.parse_args() - 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.PersonalAccessTokenAuth(args.token_name, token, site_id=args.site) + # Sign in to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { diff --git a/samples/login.py b/samples/login.py index 29e02e14e..eec967e8d 100644 --- a/samples/login.py +++ b/samples/login.py @@ -13,16 +13,17 @@ def main(): parser = argparse.ArgumentParser(description='Logs in to the server.') - + # This command is special, as it doesn't take `token-value` and it offer both token-based and password based authentication. + # Please still try to keep common options like `server` and `site` consistent across samples + # Common options: + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', help='site name') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - - parser.add_argument('--server', '-s', required=True, help='server address') - + # Options specific to this sample group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--username', '-u', help='username to sign into the server') group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') - parser.add_argument('--sitename', '-S', default='') args = parser.parse_args() @@ -37,8 +38,8 @@ def main(): # Trying to authenticate using username and password. password = getpass.getpass("Password: ") - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.sitename, args.username)) - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.sitename) + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) with server.auth.sign_in(tableau_auth): print('Logged in successfully') @@ -47,9 +48,9 @@ def main(): personal_access_token = getpass.getpass("Personal Access Token: ") print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}" - .format(args.server, args.sitename, args.token_name)) + .format(args.server, args.site, args.token_name)) tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, - personal_access_token=personal_access_token, site_id=args.sitename) + personal_access_token=personal_access_token, site_id=args.site) with server.auth.sign_in_with_personal_access_token(tableau_auth): print('Logged in successfully') diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index c31425f25..62189370c 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -8,7 +8,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -17,25 +16,28 @@ def main(): parser = argparse.ArgumentParser(description='Move one workbook from the default project to another.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-project', '-d', required=True, help='name of project to move workbook into') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') + parser.add_argument('--destination-project', '-d', required=True, help='name of project to move workbook into') args = parser.parse_args() - password = getpass.getpass("Password: ") - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server - tableau_auth = TSC.TableauAuth(args.username, password) - server = TSC.Server(args.server) - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step 2: Query workbook to move req_option = TSC.RequestOptions() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 08bde0ec6..8a97031a9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -8,7 +8,6 @@ #### import argparse -import getpass import logging import shutil import tempfile @@ -21,23 +20,28 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the" "default project of the default site to" "the default project of another site.") + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-site', '-d', required=True, help='name of site to move workbook into') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') + parser.add_argument('--destination-site', '-d', required=True, help='name of site to move workbook into') - args = parser.parse_args() - password = getpass.getpass("Password: ") + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to both sites on server - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) source_server = TSC.Server(args.server) dest_server = TSC.Server(args.server) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 6779023ba..2ebd011dc 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -10,7 +10,6 @@ #### import argparse -import getpass import logging import os.path @@ -20,26 +19,28 @@ def main(): parser = argparse.ArgumentParser(description='Demonstrate pagination on the list of workbooks on the server.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-n', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - password = getpass.getpass("Password: ") - # 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) - server = TSC.Server(args.server) - + # Sign in to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Pager returns a generator that yields one item at a time fetching # from Server only when necessary. Pager takes a server Endpoint as its # first parameter. It will call 'get' on that endpoint. To get workbooks diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 9c0099ac6..0d7f936c2 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -26,15 +26,17 @@ def main(): parser = argparse.ArgumentParser(description='Publish a datasource to server.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-i', help='site name') + parser.add_argument('--site', '-S', help='site name') parser.add_argument('--token-name', '-p', required=True, help='name of the personal access token used to sign into the server') parser.add_argument('--token-value', '-v', required=True, help='value of the personal access token used to sign into the server') - parser.add_argument('--filepath', '-f', required=True, help='filepath to the datasource to publish') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--file', '-f', required=True, help='filepath to the datasource to publish') parser.add_argument('--project', help='Project within which to publish the datasource') parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') parser.add_argument('--conn-username', help='connection username') diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index ca366cf9e..58a158b12 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -15,7 +15,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -25,29 +24,30 @@ def main(): parser = argparse.ArgumentParser(description='Publish a workbook to server.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--filepath', '-f', required=True, help='computer filepath of the workbook to publish') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('--file', '-f', required=True, help='local filepath of the workbook to publish') parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') parser.add_argument('--skip-connection-check', '-c', help='Skip live connection check', action='store_true') - parser.add_argument('--site', '-S', default='', help='id (contentUrl) of site to sign into') - args = parser.parse_args() - password = getpass.getpass("Password: ") + args = parser.parse_args() # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - server = TSC.Server(args.server) - - overwrite_true = TSC.Server.PublishMode.Overwrite - + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step 2: Get all the projects on server, then look for the default one. @@ -68,6 +68,7 @@ def main(): all_connections.append(connection2) # Step 3: If default project is found, form a new workbook item and publish. + overwrite_true = TSC.Server.PublishMode.Overwrite if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) if args.as_job: diff --git a/samples/query_permissions.py b/samples/query_permissions.py index a253adc9a..457d534ec 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -7,7 +7,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -15,30 +14,27 @@ def main(): parser = argparse.ArgumentParser(description='Query permissions of a given resource.') - parser.add_argument('--server', '-s', required=True, help='Server address') - parser.add_argument('--username', '-u', required=True, help='Username to sign into server') - parser.add_argument('--site', '-S', default=None, help='Site to sign into - default site if not provided') - parser.add_argument('-p', default=None, help='Password to sign into server') - + # Common options; please keep those in sync across all samples + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - + # Options specific to this sample parser.add_argument('resource_type', choices=['workbook', 'datasource', 'flow', 'table', 'database']) parser.add_argument('resource_id') args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # 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, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): diff --git a/samples/refresh.py b/samples/refresh.py index 96937a6e3..ec0cdbab4 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -5,7 +5,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -13,30 +12,26 @@ def main(): parser = argparse.ArgumentParser(description='Trigger a refresh task on a workbook or datasource.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('--password', '-p', default=None, help='if not specified, you will be prompted') - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - + # Options specific to this sample parser.add_argument('resource_type', choices=['workbook', 'datasource']) parser.add_argument('resource_id') args = parser.parse_args() - if args.password is None: - password = getpass.getpass("Password: ") - else: - password = args.password - # 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, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): if args.resource_type == "workbook": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index f722adb30..01f574ee4 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -6,7 +6,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -30,14 +29,16 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - + # Options specific to this sample subcommands = parser.add_subparsers() list_arguments = subcommands.add_parser('list') @@ -53,19 +54,13 @@ def main(): args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # 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) - server = TSC.Server(args.server) - server.version = '2.6' + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): args.func(server, args) diff --git a/samples/set_http_options.py b/samples/set_http_options.py index 9316dfdde..8fad2a10c 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -6,7 +6,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -15,21 +14,26 @@ def main(): parser = argparse.ArgumentParser(description='List workbooks on site, with option set to ignore SSL verification.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() - password = getpass.getpass("Password: ") - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Create required objects for sign in - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server) # Step 2: Set http options to disable verifying SSL diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 2d4761560..37526ccc8 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -7,7 +7,6 @@ import argparse -import getpass import logging import tableauserverclient as TSC @@ -15,11 +14,16 @@ def usage(args): parser = argparse.ArgumentParser(description='Set refresh schedule for a workbook or datasource.') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('--password', '-p', default=None) + # Options specific to this sample group = parser.add_mutually_exclusive_group(required=True) group.add_argument('--workbook', '-w') group.add_argument('--datasource', '-d') @@ -61,18 +65,13 @@ def assign_to_schedule(server, workbook_or_datasource, schedule): def run(args): - password = args.password - if password is None: - password = getpass.getpass("Password: ") - # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # Step 1: Sign in to server. - tableau_auth = TSC.TableauAuth(args.username, password) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): if args.workbook: item = get_workbook_by_name(server, args.workbook) diff --git a/samples/update_connection.py b/samples/update_connection.py index 3449441a4..7ac67fd76 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -5,7 +5,6 @@ #### import argparse -import getpass import logging import tableauserverclient as TSC @@ -13,14 +12,16 @@ def main(): parser = argparse.ArgumentParser(description='Update a connection on a datasource or workbook to embed credentials') + # Common options; please keep those in sync across all samples parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--site', '-S', default=None) - parser.add_argument('-p', default=None) - + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - + # Options specific to this sample parser.add_argument('resource_type', choices=['workbook', 'datasource']) parser.add_argument('resource_id') parser.add_argument('connection_id') @@ -29,17 +30,11 @@ def main(): args = parser.parse_args() - if args.p is None: - password = getpass.getpass("Password: ") - else: - password = args.p - # 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, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { From d043e58151b6ce497bc79e2f5568680903a53d81 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 23 Sep 2021 11:32:30 +0200 Subject: [PATCH 259/567] Add example for querying metadata API (#895) --- samples/metadata_query.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 samples/metadata_query.py diff --git a/samples/metadata_query.py b/samples/metadata_query.py new file mode 100644 index 000000000..7cd321f0a --- /dev/null +++ b/samples/metadata_query.py @@ -0,0 +1,64 @@ +#### +# This script demonstrates how to use the metadata API to query information on a published data source +# +# To run the script, you must have installed Python 3.5 or later. +#### + +import argparse +import logging +from pprint import pprint + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Use the metadata API to get information on a published data source.') + # Common options; please keep those in sync across all samples + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-n', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('datasource_name', nargs='?', help="The name of the published datasource. If not present, we query all data sources.") + + + args = parser.parse_args() + + # 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 to server + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # Execute the query + result = server.metadata.query(""" + query useMetadataApiToQueryOrdersDatabases($name: String){ + publishedDatasources (filter: {name: $name}) { + luid + name + description + projectName + fields { + name + } + } + }""", {"name": args.datasource_name}) + + # Display warnings/errors (if any) + if result.get("errors"): + print("### Errors/Warnings:") + pprint(result["errors"]) + + # Print the results + if result.get("data"): + print("### Results:") + pprint(result["data"]["publishedDatasources"]) + +if __name__ == '__main__': + main() From 95bb0cad5a5c88d9ebd02c71cd17f145f8dc9542 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sat, 18 Sep 2021 22:07:44 +0200 Subject: [PATCH 260/567] Expose the `fileuploads` API endpoint We had at least two independent re-implementations [1, 2] of file uploads within the last 4 months. And this was despite the fact that both projects already used TSC which would offer this functionality. Currently, the upload functionality in TSC is hard to discover as it is not exposed like all other REST functions. Instead of `server.fileuploads`, one has to first create an instance of the (undocumented) `Fileuploads` class. The upload functionality was probably because it should be usually unnecessary: The uploaded files are usually part of publishing a workbook/datasource/... and the corresponding `datasources.publish` (and similar) already take care of the upload internally. However, TSC isn't always up-to-date with new REST APIs, and by exposing file uploads directly we can make sure to offer the best possible experience to users of TSC also in those transition periods. This commit: * turns the `Fileuploads` class into a normal endpoint class which is not tied to one upload (So far, `Fileuploads` was not stateless. Now it is) * adds the endpoint to `server`, such that file uploads are available as `server.fileuploads` * adjusts all other users to use `server.fileuploads` instead of constructing an ad hoc instance of the `Fileuploads` class Documentation will be added in a separate commit. [1] https://github.com/jharris126/tableau-data-update-api-samples/blob/41f51ae4d220de55caf63e91fe9eff5694b9456a/basic/basic_incremental_load.py#L23 [2] https://github.com/tableau/hyper-api-samples/blob/382e66481ec8339407cf9cfa5d41fcdcf3f6a0fb/Community-Supported/clouddb-extractor/tableau_restapi_helpers.py#L165 --- .../server/endpoint/__init__.py | 1 + .../server/endpoint/datasources_endpoint.py | 3 +- .../server/endpoint/fileuploads_endpoint.py | 36 ++++++++----------- .../server/endpoint/flows_endpoint.py | 3 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/server.py | 2 ++ test/test_fileuploads.py | 15 +++----- 7 files changed, 24 insertions(+), 39 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 8653c0254..29fe93299 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,6 +5,7 @@ from .databases_endpoint import Databases from .endpoint import Endpoint from .favorites_endpoint import Favorites +from .fileuploads_endpoint import Fileuploads from .flows_endpoint import Flows from .exceptions import ( ServerResponseError, diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7b80c2b2b..b67332f7d 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -2,7 +2,6 @@ from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .dqw_endpoint import _DataQualityWarningEndpoint -from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem from ..query import QuerySet @@ -244,7 +243,7 @@ def publish( # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) - upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file) + upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 05a3ce17c..046406c16 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -14,7 +14,6 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): super(Fileuploads, self).__init__(parent_srv) - self.upload_id = "" @property def baseurl(self): @@ -25,21 +24,18 @@ def initiate(self): url = self.baseurl server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) - self.upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(self.upload_id)) - return self.upload_id + upload_id = fileupload_item.upload_session_id + logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + return upload_id @api(version="2.0") - def append(self, xml_request, content_type): - if not self.upload_id: - error = "File upload session must be initiated first." - raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, self.upload_id) - server_response = self.put_request(url, xml_request, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(self.upload_id)) + def append(self, upload_id, data, content_type): + url = "{0}/{1}".format(self.baseurl, upload_id) + server_response = self.put_request(url, data, content_type) + logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) - def read_chunks(self, file): + def _read_chunks(self, file): file_opened = False try: file_content = open(file, "rb") @@ -55,15 +51,11 @@ def read_chunks(self, file): break yield chunked_content - @classmethod - def upload_chunks(cls, parent_srv, file): - file_uploader = cls(parent_srv) - upload_id = file_uploader.initiate() - - chunks = file_uploader.read_chunks(file) - for chunk in chunks: - xml_request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - fileupload_item = file_uploader.append(xml_request, content_type) + def upload(self, file): + upload_id = self.initiate() + for chunk in self._read_chunks(file): + request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + fileupload_item = self.append(upload_id, request, content_type) logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) - logger.info("\tCommitting file upload...") + logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 475166aad..eb2de4ac9 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -2,7 +2,6 @@ from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .dqw_endpoint import _DataQualityWarningEndpoint -from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path @@ -169,7 +168,7 @@ def publish(self, flow_item, file_path, mode, connections=None): # Determine if chunking is required (64MB is the limit for single upload method) if os.path.getsize(file_path) >= FILESIZE_LIMIT: logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) - upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path) + upload_session_id = self.parent_srv.fileuploads.upload(file_path) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index df14674c6..a3f14c291 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,7 +1,6 @@ from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .fileuploads_endpoint import Fileuploads from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem from ...models.job_item import JobItem @@ -344,7 +343,7 @@ def publish( # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) - upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file) + upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 057c98877..a20694a92 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -24,6 +24,7 @@ DataAccelerationReport, Favorites, DataAlerts, + Fileuploads, ) from .endpoint.exceptions import ( EndpointUnavailableError, @@ -82,6 +83,7 @@ def __init__(self, server_address, use_server_version=False): self.webhooks = Webhooks(self) self.data_acceleration_report = DataAccelerationReport(self) self.data_alerts = DataAlerts(self) + self.fileuploads = Fileuploads(self) self._namespace = Namespace() if use_server_version: diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 9d115636f..51662e4a2 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -4,7 +4,6 @@ from ._utils import asset from tableauserverclient.server import Server -from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, 'fileupload_initialize.xml') @@ -22,23 +21,18 @@ def setUp(self): self.baseurl = '{}/sites/{}/fileUploads'.format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): - fileuploads = Fileuploads(self.server) - file_path = asset('SampleWB.twbx') - chunks = fileuploads.read_chunks(file_path) + chunks = self.server.fileuploads._read_chunks(file_path) for chunk in chunks: self.assertIsNotNone(chunk) def test_read_chunks_file_object(self): - fileuploads = Fileuploads(self.server) - with open(asset('SampleWB.twbx'), 'rb') as f: - chunks = fileuploads.read_chunks(f) + chunks = self.server.fileuploads._read_chunks(f) for chunk in chunks: self.assertIsNotNone(chunk) def test_upload_chunks_file_path(self): - fileuploads = Fileuploads(self.server) file_path = asset('SampleWB.twbx') upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' @@ -49,12 +43,11 @@ def test_upload_chunks_file_path(self): with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) m.put(self.baseurl + '/' + upload_id, text=append_response_xml) - actual = fileuploads.upload_chunks(self.server, file_path) + actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) def test_upload_chunks_file_object(self): - fileuploads = Fileuploads(self.server) upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' with open(asset('SampleWB.twbx'), 'rb') as file_content: @@ -65,6 +58,6 @@ def test_upload_chunks_file_object(self): with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) m.put(self.baseurl + '/' + upload_id, text=append_response_xml) - actual = fileuploads.upload_chunks(self.server, file_content) + actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) From 3abe4e94def4bc56fb188e04aa1e51fb71fcae1b Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 23 Sep 2021 13:17:55 +0200 Subject: [PATCH 261/567] Make `Fileuploads._read_chunks` exception-safe --- .../server/endpoint/fileuploads_endpoint.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 046406c16..b70cffbaa 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -43,13 +43,15 @@ def _read_chunks(self, file): except TypeError: file_content = file - while True: - chunked_content = file_content.read(CHUNK_SIZE) - if not chunked_content: - if file_opened: - file_content.close() - break - yield chunked_content + try: + while True: + chunked_content = file_content.read(CHUNK_SIZE) + if not chunked_content: + break + yield chunked_content + finally: + if file_opened: + file_content.close() def upload(self, file): upload_id = self.initiate() From 7c03396b7b52ab40c60789f87db04998314812eb Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Wed, 29 Sep 2021 09:18:59 +0200 Subject: [PATCH 262/567] Add support for scheduling Data Update jobs (#891) This commit adds support for the `datasources//data` endpoint through which one can schedule jobs to update the data within a published live-to-Hyper datasource on the server. The new `datasources.update_data` expects the arguments: * a datasource or a connection: If the datasource only contains a single connections, the datasource is sufficient to identify which Hyper file should be updated. Otherwise, for datasources with multiple connections, the connections has to be provided. This distinction happens on the server, so the client library only needs to provide a way to specify either of both. * a `request_id` which will be used to ensure idempotency on the server. This parameter is simply passed as a HTTP header . * an `actions` list, specifying how exactly the data on the server should be modified. We expect the caller to provide list following the structure documented in the REST API documentation. TSC does not validate this object and simply passes it through to the server. * an optional `payload` file: For actions like `insert`, one can provide a Hyper file which contains the newly inserted tuples or other payload data. TSC will upload this file to the server and then hand it over to the update-API endpoint. Besides the addition of the `datasources.update_data` itself, this commit also adds some infrastructure changes, e.g., to enable sending PATCH requests and HTTP headers. --- samples/update_datasource_data.py | 74 +++++++++++++++++ .../server/endpoint/datasources_endpoint.py | 29 +++++++ .../server/endpoint/endpoint.py | 17 +++- test/assets/datasource_data_update.xml | 9 ++ test/test_datasource.py | 82 +++++++++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 samples/update_datasource_data.py create mode 100644 test/assets/datasource_data_update.xml diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py new file mode 100644 index 000000000..9465ae9ee --- /dev/null +++ b/samples/update_datasource_data.py @@ -0,0 +1,74 @@ +#### +# This script demonstrates how to update the data within a published +# live-to-Hyper datasource on server. +# +# The sample is hardcoded against the `World Indicators` dataset and +# expects to receive the LUID of a published datasource containing +# that data. To create such a published datasource, you can use: +# ./publish_datasource.py --file ../test/assets/World\ Indicators.hyper +# which will print you the LUID of the datasource. +# +# Before running this script, the datasource will contain a region `Europe`. +# After running this script, that region will be gone. +# +#### + +import argparse +import uuid +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description='Delete the `Europe` region from a published `World Indicators` datasource.') + # Common options; please keep those in sync across all samples + parser.add_argument('--server', '-s', required=True, help='server address') + parser.add_argument('--site', '-S', help='site name') + parser.add_argument('--token-name', '-p', required=True, + help='name of the personal access token used to sign into the server') + parser.add_argument('--token-value', '-v', required=True, + help='value of the personal access token used to sign into the server') + parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') + # Options specific to this sample + parser.add_argument('datasource_id', help="The LUID of the `World Indicators` datasource") + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + # We use a unique `request_id` for every request. + # In case the submission of the update job fails, we won't know wether the job was submitted + # or not. It could be that the server received the request, changed the data, but then the + # network connection broke down. + # If you want to have a way to retry, e.g., inserts while making sure they aren't duplicated, + # you need to use `request_id` for that purpose. + # In our case, we don't care about retries. And the delete is idempotent anyway. + # Hence, we simply use a randomly generated request id. + request_id = str(uuid.uuid4()) + + # This action will delete all rows with `Region=Europe` from the published data source. + # Other actions (inserts, updates, ...) are also available. For more information see + # https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm + actions = [ + { + "action": "delete", + "target-table": "Extract", + "target-schema": "Extract", + "condition": {"op": "eq", "target-col": "Region", "const": {"type": "string", "v": "Europe"}} + } + ] + + job = server.datasources.update_data(args.datasource_id, request_id=request_id, actions=actions) + + # TODO: Add a flag that will poll and wait for the returned job to be done + print(job) + +if __name__ == '__main__': + main() diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index b67332f7d..997921312 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -18,6 +18,7 @@ import copy import cgi from contextlib import closing +import json # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -282,6 +283,34 @@ def publish( logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource + @api(version="3.13") + def update_data(self, datasource_or_connection_item, *, request_id, actions, payload = None): + if isinstance(datasource_or_connection_item, DatasourceItem): + datasource_id = datasource_or_connection_item.id + url = "{0}/{1}/data".format(self.baseurl, datasource_id) + elif isinstance(datasource_or_connection_item, ConnectionItem): + datasource_id = datasource_or_connection_item.datasource_id + connection_id = datasource_or_connection_item.id + url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + else: + assert isinstance(datasource_or_connection_item, str) + url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + + if payload is not None: + if not os.path.isfile(payload): + error = "File path does not lead to an existing file." + raise IOError(error) + + logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + upload_session_id = self.parent_srv.fileuploads.upload(payload) + url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + + json_request = json.dumps({"actions": actions}) + parameters = {"headers": {"requestid": request_id}} + server_response = self.patch_request(url, json_request, "application/json", parameters=parameters) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job + @api(version="2.0") def populate_permissions(self, item): self._permissions.populate(item) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index f7d88b0e6..31291abc9 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -55,7 +55,9 @@ def _make_request( ): parameters = parameters or {} parameters.update(self.parent_srv.http_options) - parameters["headers"] = Endpoint._make_common_headers(auth_token, content_type) + if not "headers" in parameters: + parameters["headers"] = {} + parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) if content is not None: parameters["data"] = content @@ -118,13 +120,14 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request=None, content_type="text/xml"): + def put_request(self, url, xml_request=None, content_type="text/xml", parameters=None): return self._make_request( self.parent_srv.session.put, url, content=xml_request, auth_token=self.parent_srv.auth_token, content_type=content_type, + parameters=parameters, ) def post_request(self, url, xml_request, content_type="text/xml", parameters=None): @@ -137,6 +140,16 @@ def post_request(self, url, xml_request, content_type="text/xml", parameters=Non parameters=parameters, ) + def patch_request(self, url, xml_request, content_type="text/xml", parameters=None): + return self._make_request( + self.parent_srv.session.patch, + url, + content=xml_request, + auth_token=self.parent_srv.auth_token, + content_type=content_type, + parameters=parameters, + ) + def api(version): """Annotate the minimum supported version for an endpoint. diff --git a/test/assets/datasource_data_update.xml b/test/assets/datasource_data_update.xml new file mode 100644 index 000000000..305caaf0b --- /dev/null +++ b/test/assets/datasource_data_update.xml @@ -0,0 +1,9 @@ + + + + + + 7ecaccd8-39b0-4875-a77d-094f6e930019 + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 42d1dfade..e4ef01a29 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,3 +1,4 @@ +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads import unittest from io import BytesIO import os @@ -22,6 +23,7 @@ PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' REFRESH_XML = 'datasource_refresh.xml' UPDATE_XML = 'datasource_update.xml' +UPDATE_DATA_XML = 'datasource_data_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -355,6 +357,86 @@ def test_refresh_object(self): # We only check the `id`; remaining fields are already tested in `test_refresh_id` self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + def test_update_data_datasource_object(self): + """Calling `update_data` with a `DatasourceItem` should update that datasource""" + self.server.version = "3.13" + self.baseurl = self.server.datasources.baseurl + + datasource = TSC.DatasourceItem('') + datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + response_xml = read_xml_asset(UPDATE_DATA_XML) + with requests_mock.mock() as m: + m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', + status_code=202, headers={"requestid": "test_id"}, text=response_xml) + new_job = self.server.datasources.update_data(datasource, request_id="test_id", actions=[]) + + self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual('UpdateUploadedFile', new_job.type) + self.assertEqual(None, new_job.progress) + self.assertEqual('2021-09-18T09:40:12Z', format_datetime(new_job.created_at)) + self.assertEqual(-1, new_job.finish_code) + + def test_update_data_connection_object(self): + """Calling `update_data` with a `ConnectionItem` should update that connection""" + self.server.version = "3.13" + self.baseurl = self.server.datasources.baseurl + + connection = TSC.ConnectionItem() + connection._datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + connection._id = '7ecaccd8-39b0-4875-a77d-094f6e930019' + response_xml = read_xml_asset(UPDATE_DATA_XML) + with requests_mock.mock() as m: + m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data', + status_code=202, headers={"requestid": "test_id"}, text=response_xml) + new_job = self.server.datasources.update_data(connection, request_id="test_id", actions=[]) + + # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + + def test_update_data_datasource_string(self): + """For convenience, calling `update_data` with a `str` should update the datasource with the corresponding UUID""" + self.server.version = "3.13" + self.baseurl = self.server.datasources.baseurl + + datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + response_xml = read_xml_asset(UPDATE_DATA_XML) + with requests_mock.mock() as m: + m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', + status_code=202, headers={"requestid": "test_id"}, text=response_xml) + new_job = self.server.datasources.update_data(datasource_id, request_id="test_id", actions=[]) + + # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + + def test_update_data_datasource_payload_file(self): + """If `payload` is present, we upload it and associate the job with it""" + self.server.version = "3.13" + self.baseurl = self.server.datasources.baseurl + + datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + mock_upload_id = '10051:c3e56879876842d4b3600f20c1f79876-0:0' + response_xml = read_xml_asset(UPDATE_DATA_XML) + with requests_mock.mock() as rm, \ + unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): + rm.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=' + mock_upload_id, + status_code=202, headers={"requestid": "test_id"}, text=response_xml) + new_job = self.server.datasources.update_data(datasource_id, request_id="test_id", + actions=[], payload=asset('World Indicators.hyper')) + + # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + + def test_update_data_datasource_invalid_payload_file(self): + """If `payload` points to a non-existing file, we report an error""" + self.server.version = "3.13" + self.baseurl = self.server.datasources.baseurl + datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + with self.assertRaises(IOError) as cm: + self.server.datasources.update_data(datasource_id, request_id="test_id", + actions=[], payload='no/such/file.missing') + exception = cm.exception + self.assertEqual(str(exception), "File path does not lead to an existing file.") + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) From 9ac17e4deb513fe85518e88ece88e781ae0c79ca Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Mon, 4 Oct 2021 13:24:48 +0200 Subject: [PATCH 263/567] Rename `datasource.update_data` to `datasource.update_hyper_data` (#906) As suggested by @dzucker-tab in #893 --- samples/update_datasource_data.py | 2 +- .../server/endpoint/datasources_endpoint.py | 2 +- test/test_datasource.py | 42 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 9465ae9ee..3633ebaf6 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -65,7 +65,7 @@ def main(): } ] - job = server.datasources.update_data(args.datasource_id, request_id=request_id, actions=actions) + job = server.datasources.update_hyper_data(args.datasource_id, request_id=request_id, actions=actions) # TODO: Add a flag that will poll and wait for the returned job to be done print(job) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 997921312..c031004e0 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -284,7 +284,7 @@ def publish( return new_datasource @api(version="3.13") - def update_data(self, datasource_or_connection_item, *, request_id, actions, payload = None): + def update_hyper_data(self, datasource_or_connection_item, *, request_id, actions, payload = None): if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = "{0}/{1}/data".format(self.baseurl, datasource_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index e4ef01a29..68d6d1384 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -23,7 +23,7 @@ PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' REFRESH_XML = 'datasource_refresh.xml' UPDATE_XML = 'datasource_update.xml' -UPDATE_DATA_XML = 'datasource_data_update.xml' +UPDATE_HYPER_DATA_XML = 'datasource_data_update.xml' UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' @@ -357,18 +357,18 @@ def test_refresh_object(self): # We only check the `id`; remaining fields are already tested in `test_refresh_id` self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) - def test_update_data_datasource_object(self): - """Calling `update_data` with a `DatasourceItem` should update that datasource""" + def test_update_hyper_data_datasource_object(self): + """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl datasource = TSC.DatasourceItem('') datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - response_xml = read_xml_asset(UPDATE_DATA_XML) + response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_data(datasource, request_id="test_id", actions=[]) + new_job = self.server.datasources.update_hyper_data(datasource, request_id="test_id", actions=[]) self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) self.assertEqual('UpdateUploadedFile', new_job.type) @@ -376,63 +376,63 @@ def test_update_data_datasource_object(self): self.assertEqual('2021-09-18T09:40:12Z', format_datetime(new_job.created_at)) self.assertEqual(-1, new_job.finish_code) - def test_update_data_connection_object(self): - """Calling `update_data` with a `ConnectionItem` should update that connection""" + def test_update_hyper_data_connection_object(self): + """Calling `update_hyper_data` with a `ConnectionItem` should update that connection""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl connection = TSC.ConnectionItem() connection._datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' connection._id = '7ecaccd8-39b0-4875-a77d-094f6e930019' - response_xml = read_xml_asset(UPDATE_DATA_XML) + response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data', status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_data(connection, request_id="test_id", actions=[]) + new_job = self.server.datasources.update_hyper_data(connection, request_id="test_id", actions=[]) - # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) - def test_update_data_datasource_string(self): - """For convenience, calling `update_data` with a `str` should update the datasource with the corresponding UUID""" + def test_update_hyper_data_datasource_string(self): + """For convenience, calling `update_hyper_data` with a `str` should update the datasource with the corresponding UUID""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - response_xml = read_xml_asset(UPDATE_DATA_XML) + response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_data(datasource_id, request_id="test_id", actions=[]) + new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[]) - # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) - def test_update_data_datasource_payload_file(self): + def test_update_hyper_data_datasource_payload_file(self): """If `payload` is present, we upload it and associate the job with it""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' mock_upload_id = '10051:c3e56879876842d4b3600f20c1f79876-0:0' - response_xml = read_xml_asset(UPDATE_DATA_XML) + response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as rm, \ unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): rm.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=' + mock_upload_id, status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_data(datasource_id, request_id="test_id", + new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[], payload=asset('World Indicators.hyper')) - # We only check the `id`; remaining fields are already tested in `test_update_data_datasource_object` + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) - def test_update_data_datasource_invalid_payload_file(self): + def test_update_hyper_data_datasource_invalid_payload_file(self): """If `payload` points to a non-existing file, we report an error""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' with self.assertRaises(IOError) as cm: - self.server.datasources.update_data(datasource_id, request_id="test_id", + self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[], payload='no/such/file.missing') exception = cm.exception self.assertEqual(str(exception), "File path does not lead to an existing file.") From 9ccc7133fa73de9f4c607afc035c5dc7f261985f Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 7 Oct 2021 00:51:02 +0200 Subject: [PATCH 264/567] Add `jobs.wait_for_job` method (#903) This commit adds a `wait_for_job` method which will repeatedly poll a job's status until that job is finished. Internally, it uses an exponential backoff for the polling intervals. That way, it is snappy for fast-running jobs without putting too much load on the server for long-running jobs. It returns the successfully finished `JobItem` object which might be of interest to the caller, e.g. to inspect the reported `started_at` `finished_at` times or the `notes`. For failed jobs, `wait_for_job` raises an exception. That way, we ensure that errors in jobs don't accidentally go unnoticed. The `jobs` object can still be retrieved from the exception object, if required. --- samples/refresh.py | 13 ++-- samples/update_datasource_data.py | 8 ++- tableauserverclient/exponential_backoff.py | 30 +++++++++ tableauserverclient/models/job_item.py | 12 +++- .../server/endpoint/exceptions.py | 13 ++++ .../server/endpoint/jobs_endpoint.py | 27 +++++++- test/_utils.py | 18 ++++++ test/test_datasource.py | 2 +- test/test_exponential_backoff.py | 62 +++++++++++++++++++ test/test_job.py | 41 ++++++++++-- test/test_workbook.py | 2 +- 11 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 tableauserverclient/exponential_backoff.py create mode 100644 test/test_exponential_backoff.py diff --git a/samples/refresh.py b/samples/refresh.py index ec0cdbab4..7b2618b6e 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -39,16 +39,19 @@ def main(): resource = server.workbooks.get_by_id(args.resource_id) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - results = server.workbooks.refresh(args.resource_id) + job = server.workbooks.refresh(args.resource_id) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - results = server.datasources.refresh(resource) - - print(results) - # TODO: Add a flag that will poll and wait for the returned job to be done + job = server.datasources.refresh(resource) + + print(f"Update job posted (ID: {job.id})") + print("Waiting for job...") + # `wait_for_job` will throw if the job isn't executed successfully + job = server.jobs.wait_for_job(job) + print("Job finished succesfully") if __name__ == '__main__': diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 3633ebaf6..74c8ea6fb 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -67,8 +67,12 @@ def main(): job = server.datasources.update_hyper_data(args.datasource_id, request_id=request_id, actions=actions) - # TODO: Add a flag that will poll and wait for the returned job to be done - print(job) + print(f"Update job posted (ID: {job.id})") + print("Waiting for job...") + # `wait_for_job` will throw if the job isn't executed successfully + job = server.jobs.wait_for_job(job) + print("Job finished succesfully") + if __name__ == '__main__': main() diff --git a/tableauserverclient/exponential_backoff.py b/tableauserverclient/exponential_backoff.py new file mode 100644 index 000000000..2b3ded109 --- /dev/null +++ b/tableauserverclient/exponential_backoff.py @@ -0,0 +1,30 @@ +import time + +# Polling for server-side events (such as job completion) uses exponential backoff for the sleep intervals between polls +ASYNC_POLL_MIN_INTERVAL=0.5 +ASYNC_POLL_MAX_INTERVAL=30 +ASYNC_POLL_BACKOFF_FACTOR=1.4 + + +class ExponentialBackoffTimer(): + def __init__(self, *, timeout=None): + self.start_time = time.time() + self.timeout = timeout + self.current_sleep_interval = ASYNC_POLL_MIN_INTERVAL + + def sleep(self): + max_sleep_time = ASYNC_POLL_MAX_INTERVAL + if self.timeout is not None: + elapsed = (time.time() - self.start_time) + if elapsed >= self.timeout: + raise TimeoutError(f"Timeout after {elapsed} seconds waiting for asynchronous event") + remaining_time = self.timeout - elapsed + # Usually, we would sleep for `ASYNC_POLL_MAX_INTERVAL`, but we don't want to sleep over the timeout + max_sleep_time = min(ASYNC_POLL_MAX_INTERVAL, remaining_time) + # We want to sleep at least for `ASYNC_POLL_MIN_INTERVAL`. This is important to ensure that, as we get + # closer to the timeout, we don't accidentally wake up multiple times and hit the server in rapid succession + # due to waking up to early from the `sleep`. + max_sleep_time = max(max_sleep_time, ASYNC_POLL_MIN_INTERVAL) + + time.sleep(min(self.current_sleep_interval, max_sleep_time)) + self.current_sleep_interval *= ASYNC_POLL_BACKOFF_FACTOR \ No newline at end of file diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 7a3a50861..2a8b6b509 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -3,6 +3,16 @@ class JobItem(object): + class FinishCode: + """ + Status codes as documented on + https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job + """ + Success = 0 + Failed = 1 + Cancelled = 2 + + def __init__( self, id_, @@ -89,7 +99,7 @@ def _parse_element(cls, element, ns): created_at = parse_datetime(element.get("createdAt", None)) started_at = parse_datetime(element.get("startedAt", None)) completed_at = parse_datetime(element.get("completedAt", None)) - finish_code = element.get("finishCode", -1) + finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None mode = element.get("mode", None) return cls( diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9a9a81d77..693817ddc 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -64,3 +64,16 @@ def __str__(self): from pprint import pformat return pformat(self.error) + + +class JobFailedException(Exception): + def __init__(self, job): + self.notes = job.notes + self.job = job + + def __str__(self): + return f"Job {self.job.id} failed with notes {self.notes}" + + +class JobCanceledException(JobFailedException): + pass diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 6079ca788..906d4a19e 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,6 +1,8 @@ from .endpoint import Endpoint, api +from .exceptions import JobCanceledException, JobFailedException from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase +from ...exponential_backoff import ExponentialBackoffTimer import logging @@ -12,7 +14,6 @@ logger = logging.getLogger("tableau.endpoint.jobs") - class Jobs(Endpoint): @property def baseurl(self): @@ -48,3 +49,27 @@ def get_by_id(self, job_id): server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job + + def wait_for_job(self, job_id, *, timeout=None): + if isinstance(job_id, JobItem): + job_id = job_id.id + assert isinstance(job_id, str) + logger.debug(f"Waiting for job {job_id}") + + backoffTimer = ExponentialBackoffTimer(timeout=timeout) + job = self.get_by_id(job_id) + while job.completed_at is None: + backoffTimer.sleep() + job = self.get_by_id(job_id) + logger.debug(f"\tJob {job_id} progress={job.progress}") + + logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + + if job.finish_code == JobItem.FinishCode.Success: + return job + elif job.finish_code == JobItem.FinishCode.Failed: + raise JobFailedException(job) + elif job.finish_code == JobItem.FinishCode.Cancelled: + raise JobCanceledException(job) + else: + raise AssertionError("Unexpected finish_code in job", job) diff --git a/test/_utils.py b/test/_utils.py index ecabf53a4..93d7a9334 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager +import unittest import os.path TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') @@ -14,3 +16,19 @@ def read_xml_asset(filename): def read_xml_assets(*args): return map(read_xml_asset, args) + + +@contextmanager +def mocked_time(): + mock_time = 0 + + def sleep_mock(interval): + nonlocal mock_time + mock_time += interval + + def get_time(): + return mock_time + + patch = unittest.mock.patch + with patch("time.sleep", sleep_mock), patch("time.time", get_time): + yield get_time diff --git a/test/test_datasource.py b/test/test_datasource.py index 68d6d1384..4c65e8dc9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -317,7 +317,7 @@ def test_publish_async(self): self.assertEqual('PublishDatasource', new_job.type) self.assertEqual('0', new_job.progress) self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) - self.assertEqual('1', new_job.finish_code) + self.assertEqual(1, new_job.finish_code) def test_publish_unnamed_file_object(self): new_datasource = TSC.DatasourceItem('test') diff --git a/test/test_exponential_backoff.py b/test/test_exponential_backoff.py new file mode 100644 index 000000000..57229d4ce --- /dev/null +++ b/test/test_exponential_backoff.py @@ -0,0 +1,62 @@ +import unittest +from ._utils import mocked_time +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer + + +class ExponentialBackoffTests(unittest.TestCase): + def test_exponential(self): + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer() + # The creation of our mock shouldn't sleep + self.assertAlmostEqual(mock_time(), 0) + # The first sleep sleeps for a rather short time, the following sleeps become longer + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 0.5) + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 1.2) + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 2.18) + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 3.552) + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 5.4728) + + + def test_exponential_saturation(self): + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer() + for _ in range(99): + exponentialBackoff.sleep() + # We don't increase the sleep time above 30 seconds. + # Otherwise, the exponential sleep time could easily + # reach minutes or even hours between polls + for _ in range(5): + s = mock_time() + exponentialBackoff.sleep() + slept = mock_time() - s + self.assertAlmostEqual(slept, 30) + + + def test_timeout(self): + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer(timeout=4.5) + for _ in range(4): + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 3.552) + # Usually, the following sleep would sleep until 5.5, but due to + # the timeout we wait less; thereby we make sure to take the timeout + # into account as good as possible + exponentialBackoff.sleep() + self.assertAlmostEqual(mock_time(), 4.5) + # The next call to `sleep` will raise a TimeoutError + with self.assertRaises(TimeoutError): + exponentialBackoff.sleep() + + + def test_timeout_zero(self): + with mocked_time() as mock_time: + # The construction of the timer doesn't throw, yet + exponentialBackoff = ExponentialBackoffTimer(timeout = 0) + # But the first `sleep` immediately throws + with self.assertRaises(TimeoutError): + exponentialBackoff.sleep() diff --git a/test/test_job.py b/test/test_job.py index 08b98b815..70bca996c 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -4,12 +4,16 @@ import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc -from ._utils import read_xml_asset +from tableauserverclient.server.endpoint.exceptions import JobFailedException +from ._utils import read_xml_asset, mocked_time TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') GET_XML = 'job_get.xml' GET_BY_ID_XML = 'job_get_by_id.xml' +GET_BY_ID_FAILED_XML = 'job_get_by_id_failed.xml' +GET_BY_ID_CANCELLED_XML = 'job_get_by_id_cancelled.xml' +GET_BY_ID_INPROGRESS_XML = 'job_get_by_id_inprogress.xml' class JobTests(unittest.TestCase): @@ -49,9 +53,6 @@ def test_get_by_id(self): m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) - created_at = datetime(2020, 5, 13, 20, 23, 45, tzinfo=utc) - updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) - ended_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) self.assertEqual(job_id, job.id) self.assertListEqual(job.notes, ['Job detail notes']) @@ -72,3 +73,35 @@ def test_cancel_item(self): with requests_mock.mock() as m: m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) self.server.jobs.cancel(job) + + + def test_wait_for_job_finished(self): + # Waiting for an already finished job, directly returns that job's info + response_xml = read_xml_asset(GET_BY_ID_XML) + job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + job = self.server.jobs.wait_for_job(job_id) + + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ['Job detail notes']) + + + def test_wait_for_job_failed(self): + # Waiting for a failed job raises an exception + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + with self.assertRaises(JobFailedException): + self.server.jobs.wait_for_job(job_id) + + + def test_wait_for_job_timeout(self): + # Waiting for a job which doesn't terminate will throw an exception + response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) + job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + with self.assertRaises(TimeoutError): + self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_workbook.py b/test/test_workbook.py index d3a3b59b4..459b1f905 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -616,7 +616,7 @@ def test_publish_async(self): self.assertEqual('PublishWorkbook', new_job.type) self.assertEqual('0', new_job.progress) self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) - self.assertEqual('1', new_job.finish_code) + self.assertEqual(1, new_job.finish_code) def test_publish_invalid_file(self): new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') From a1c3f94466cba2bde6312fa45000f06221132c3b Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 7 Oct 2021 01:33:12 +0200 Subject: [PATCH 265/567] Remove `basestring` hack for Python 2.x compatibility TSC only supports Python 3.5+ and Python 2.7 reached end-of-life already on Jan 1st, 2020. Let's remove that hack from our code, and move on... --- tableauserverclient/models/property_decorators.py | 8 +------- tableauserverclient/server/endpoint/jobs_endpoint.py | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index b3466dea7..ea2a62380 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -3,12 +3,6 @@ from functools import wraps from ..datetime_helpers import parse_datetime -try: - basestring -except NameError: - # In case we are in python 3 the string check is different - basestring = str - def property_is_enum(enum_type): def property_type_decorator(func): @@ -134,7 +128,7 @@ def property_is_datetime(func): def wrapper(self, value): if isinstance(value, datetime.datetime): return func(self, value) - if not isinstance(value, basestring): + if not isinstance(value, str): raise ValueError( "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) ) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 906d4a19e..4c975c523 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,12 +6,6 @@ import logging -try: - basestring -except NameError: - # In case we are in python 3 the string check is different - basestring = str - logger = logging.getLogger("tableau.endpoint.jobs") class Jobs(Endpoint): @@ -22,7 +16,7 @@ def baseurl(self): @api(version="2.6") def get(self, job_id=None, req_options=None): # Backwards Compatibility fix until we rev the major version - if job_id is not None and isinstance(job_id, basestring): + if job_id is not None and isinstance(job_id, str): import warnings warnings.warn("Jobs.get(job_id) is deprecated, update code to use Jobs.get_by_id(job_id)") From acda7f57d24c505da7da97e9be98dc3b427eda61 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 7 Oct 2021 13:54:52 +0200 Subject: [PATCH 266/567] Add missing test assets Should have been part of #903, but I forgot to `git add` them :/ --- test/assets/job_get_by_id_failed.xml | 9 +++++++++ test/assets/job_get_by_id_inprogress.xml | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 test/assets/job_get_by_id_failed.xml create mode 100644 test/assets/job_get_by_id_inprogress.xml diff --git a/test/assets/job_get_by_id_failed.xml b/test/assets/job_get_by_id_failed.xml new file mode 100644 index 000000000..c7456008e --- /dev/null +++ b/test/assets/job_get_by_id_failed.xml @@ -0,0 +1,9 @@ + + + + + + c569ee62-9204-416f-843d-5ccfebc0231b + + + \ No newline at end of file diff --git a/test/assets/job_get_by_id_inprogress.xml b/test/assets/job_get_by_id_inprogress.xml new file mode 100644 index 000000000..7a23fb99d --- /dev/null +++ b/test/assets/job_get_by_id_inprogress.xml @@ -0,0 +1,9 @@ + + + + + + c569ee62-9204-416f-843d-5ccfebc0231b + + + \ No newline at end of file From 168638c2d04deef07bc5a82794276b0b18994b5e Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Thu, 7 Oct 2021 14:05:40 +0200 Subject: [PATCH 267/567] Stop supporting Python 3.5 Python 3.5 is already end-of-life and no longer receives security patches for over a year now. Also, I recently added an f-string because all Python versions which are still in support, also support f-strings. I was unpleasantly surprised when I had to realize that we still claim to support Python 3.5 which doesn't have f-strings... --- .github/workflows/run-tests.yml | 2 +- README.md | 2 +- samples/add_default_permission.py | 2 +- samples/create_group.py | 2 +- samples/create_project.py | 2 +- samples/create_schedules.py | 2 +- samples/download_view_image.py | 2 +- samples/export.py | 2 +- samples/export_wb.py | 2 +- samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/login.py | 2 +- samples/metadata_query.py | 2 +- samples/move_workbook_projects.py | 2 +- samples/move_workbook_sites.py | 2 +- samples/publish_datasource.py | 2 +- samples/publish_workbook.py | 2 +- samples/query_permissions.py | 2 +- samples/refresh.py | 2 +- samples/refresh_tasks.py | 2 +- samples/set_http_options.py | 2 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- 25 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9a51ac7a9..61476132f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2] + python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2] runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index a5445e052..b454dd4c7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code and sample files. Python versions 3.5 and up are supported. +This repository contains Python source code and sample files. Python versions 3.6 and up are supported. For more information on installing and using TSC, see the documentation: diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 77ad58a11..8018c7b30 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to add default permissions using TSC -# To run the script, you must have installed Python 3.5 and later. +# To run the script, you must have installed Python 3.6 or later. # # In order to demonstrate adding a new default permission, this sample will create # a new project and add a new capability to the new project, for the default "All users" group. diff --git a/samples/create_group.py b/samples/create_group.py index 4459eb96a..ad0e6cc4f 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create a group using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index b3b28c2dc..814d35617 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 3c2627bf6..39332713b 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 17cc2000b..3ac2ed4d5 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -5,7 +5,7 @@ # For more information, refer to the documentations on 'Query View Image' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/export.py b/samples/export.py index 2b6de57f9..6317ec53b 100644 --- a/samples/export.py +++ b/samples/export.py @@ -2,7 +2,7 @@ # This script demonstrates how to export a view using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/export_wb.py b/samples/export_wb.py index a9b4d60be..2be476130 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -4,7 +4,7 @@ # # You will need to do `pip install PyPDF2` to use this sample. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 7f160f66d..24dee791d 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter and sort groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index e4f695fda..23b350fa6 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to filter and sort on the name of the projects present on site. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index f9fa173e5..196da4b01 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index 8a6407e0d..867757668 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index eec967e8d..c8af97505 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 7cd321f0a..c9cf7394c 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use the metadata API to query information on a published data source # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 62189370c..c8227aeda 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 8a97031a9..e0475ac06 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 0d7f936c2..8ae744185 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -15,7 +15,7 @@ # more information on personal access tokens, refer to the documentations: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 58a158b12..fcfcddc15 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 457d534ec..0909f915d 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to query for permissions using TSC -# To run the script, you must have installed Python 3.5 and later. +# To run the script, you must have installed Python 3.6 or later. # # Example usage: 'python query_permissions.py -s https://10ax.online.tableau.com --site # devSite123 -u tabby@tableau.com workbook b4065286-80f0-11ea-af1b-cb7191f48e45' diff --git a/samples/refresh.py b/samples/refresh.py index 7b2618b6e..3eed5b4be 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 01f574ee4..bf69d064a 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/set_http_options.py b/samples/set_http_options.py index 8fad2a10c..40ed9167e 100644 --- a/samples/set_http_options.py +++ b/samples/set_http_options.py @@ -2,7 +2,7 @@ # This script demonstrates how to set http options. It will set the option # to not verify SSL certificate, and query all workbooks on site. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 37526ccc8..862ea2372 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -2,7 +2,7 @@ # This script demonstrates how to set the refresh schedule for # a workbook or datasource. # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### diff --git a/samples/update_connection.py b/samples/update_connection.py index 7ac67fd76..0e87217e8 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 3.5 or later. +# To run the script, you must have installed Python 3.6 or later. #### import argparse From 55dd640100c2d64b734815f12c3e14f7f20d18c1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sat, 9 Oct 2021 00:35:51 -0700 Subject: [PATCH 268/567] un-re-over-merge --- tableauserverclient/models/datasource_item.py | 5 ++++- test/assets/datasource_get_by_id.xml | 2 +- test/test_datasource.py | 1 - 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 6c449408c..5b23341d0 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -136,6 +136,10 @@ def datasource_type(self): def description(self): return self._description + @description.setter + def description(self, value): + self._description = value + @property def updated_at(self): return self._updated_at @@ -174,7 +178,6 @@ def _parse_common_elements(self, datasource_xml, ns): _, _, _, - _, encrypt_extracts, has_extracts, _, diff --git a/test/assets/datasource_get_by_id.xml b/test/assets/datasource_get_by_id.xml index d5dcf89ee..53434b8cc 100644 --- a/test/assets/datasource_get_by_id.xml +++ b/test/assets/datasource_get_by_id.xml @@ -1,6 +1,6 @@ - + diff --git a/test/test_datasource.py b/test/test_datasource.py index ae2f85c23..52a5eabe3 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -105,7 +105,6 @@ def test_get_by_id(self): self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_datasource.project_id) self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_datasource.owner_id) self.assertEqual(set(['world', 'indicators', 'sample']), single_datasource.tags) - self.assertEqual("test-ds", single_datasource.description) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self): From 428eb550dbeee9aea0e7941f67ceb37819c439fa Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 19 Oct 2021 12:32:51 -0500 Subject: [PATCH 269/567] Add FlowRun Item and Endpoints. (#884) * Add tests for fetching flow runs * Implement basics of FlowRuns * Add tests for cancel flow run * Make FlowRuns a Queryset endpoint for easier filtering * Add test for flow refresh endpoint * Align to naming conventions * Apply name change consistently * Change flowrun_id into flow_run_id * Add wait_for_job to FlowRun * Tag wait_for_job with version number * Rewrite flow_run to use ExponentialBackoffTimer * Test flow run wait with backoff * Remove 3.5 from test matrix * Standardize spelling of cancelled Co-authored-by: Jordan Woods --- tableauserverclient/__init__.py | 1 + tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/flow_run_item.py | 106 ++++++++++++++++++ tableauserverclient/models/job_item.py | 19 ++++ tableauserverclient/server/__init__.py | 1 + .../server/endpoint/__init__.py | 1 + .../server/endpoint/exceptions.py | 13 ++- .../server/endpoint/flow_runs_endpoint.py | 76 +++++++++++++ .../server/endpoint/jobs_endpoint.py | 5 +- tableauserverclient/server/server.py | 2 + test/_utils.py | 5 +- test/assets/flow_refresh.xml | 11 ++ test/assets/flow_runs_get.xml | 19 ++++ test/assets/flow_runs_get_by_id.xml | 10 ++ test/assets/flow_runs_get_by_id_failed.xml | 10 ++ .../assets/flow_runs_get_by_id_inprogress.xml | 10 ++ test/test_flow.py | 20 ++++ test/test_flowruns.py | 104 +++++++++++++++++ 18 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 tableauserverclient/models/flow_run_item.py create mode 100644 tableauserverclient/server/endpoint/flow_runs_endpoint.py create mode 100644 test/assets/flow_refresh.xml create mode 100644 test/assets/flow_runs_get.xml create mode 100644 test/assets/flow_runs_get_by_id.xml create mode 100644 test/assets/flow_runs_get_by_id_failed.xml create mode 100644 test/assets/flow_runs_get_by_id_inprogress.xml create mode 100644 test/test_flowruns.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index fcce4e0c7..2ad65d71e 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -34,6 +34,7 @@ FlowItem, WebhookItem, PersonalAccessTokenAuth, + FlowRunItem ) from .server import ( RequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index c0ddc2e75..e5945782d 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -10,6 +10,7 @@ from .favorites_item import FavoriteItem from .group_item import GroupItem from .flow_item import FlowItem +from .flow_run_item import FlowRunItem from .interval_item import ( IntervalItem, DailyInterval, diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py new file mode 100644 index 000000000..251c667b1 --- /dev/null +++ b/tableauserverclient/models/flow_run_item.py @@ -0,0 +1,106 @@ +import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime +import itertools + + +class FlowRunItem(object): + def __init__(self) -> None: + self._id=None + self._flow_id=None + self._status=None + self._started_at=None + self._completed_at=None + self._progress=None + self._background_job_id=None + + + @property + def id(self): + return self._id + + + @property + def flow_id(self): + return self._flow_id + + + @property + def status(self): + return self._status + + + @property + def started_at(self): + return self._started_at + + + @property + def completed_at(self): + return self._completed_at + + + @property + def progress(self): + return self._progress + + + @property + def background_job_id(self): + return self._background_job_id + + + def _set_values( + self, + id, + flow_id, + status, + started_at, + completed_at, + progress, + background_job_id, + ): + if id is not None: + self._id = id + if flow_id is not None: + self._flow_id = flow_id + if status is not None: + self._status = status + if started_at is not None: + self._started_at = started_at + if completed_at is not None: + self._completed_at = completed_at + if progress is not None: + self._progress = progress + if background_job_id is not None: + self._background_job_id = background_job_id + + + @classmethod + def from_response(cls, resp, ns): + all_flowrun_items = list() + parsed_response = ET.fromstring(resp) + all_flowrun_xml = itertools.chain( + parsed_response.findall(".//t:flowRun[@id]", namespaces=ns), + parsed_response.findall(".//t:flowRuns[@id]", namespaces=ns) + ) + + for flowrun_xml in all_flowrun_xml: + parsed = cls._parse_element(flowrun_xml, ns) + flowrun_item = cls() + flowrun_item._set_values(**parsed) + all_flowrun_items.append(flowrun_item) + return all_flowrun_items + + + @staticmethod + def _parse_element(flowrun_xml, ns): + result = {} + result['id'] = flowrun_xml.get("id", None) + result['flow_id'] = flowrun_xml.get("flowId", None) + result['status'] = flowrun_xml.get("status", None) + result['started_at'] = parse_datetime(flowrun_xml.get("startedAt", None)) + result['completed_at'] = parse_datetime(flowrun_xml.get("completedAt", None)) + result['progress'] = flowrun_xml.get("progress", None) + result['background_job_id'] = flowrun_xml.get("backgroundJobId", None) + + return result diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 2a8b6b509..8c21b24e6 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from .flow_run_item import FlowRunItem from ..datetime_helpers import parse_datetime @@ -24,6 +25,7 @@ def __init__( finish_code=0, notes=None, mode=None, + flow_run=None, ): self._id = id_ self._type = job_type @@ -34,6 +36,7 @@ def __init__( self._finish_code = finish_code self._notes = notes or [] self._mode = mode + self._flow_run = flow_run @property def id(self): @@ -76,6 +79,14 @@ def mode(self, value): # check for valid data here self._mode = value + @property + def flow_run(self): + return self._flow_run + + @flow_run.setter + def flow_run(self, value): + self._flow_run = value + def __repr__(self): return ( " + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml new file mode 100644 index 000000000..bdce4cdfb --- /dev/null +++ b/test/assets/flow_runs_get.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_runs_get_by_id.xml b/test/assets/flow_runs_get_by_id.xml new file mode 100644 index 000000000..3a768fab4 --- /dev/null +++ b/test/assets/flow_runs_get_by_id.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/test/assets/flow_runs_get_by_id_failed.xml b/test/assets/flow_runs_get_by_id_failed.xml new file mode 100644 index 000000000..9e766680b --- /dev/null +++ b/test/assets/flow_runs_get_by_id_failed.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/test/assets/flow_runs_get_by_id_inprogress.xml b/test/assets/flow_runs_get_by_id_inprogress.xml new file mode 100644 index 000000000..42e1a77f9 --- /dev/null +++ b/test/assets/flow_runs_get_by_id_inprogress.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/test/test_flow.py b/test/test_flow.py index f5c057c30..545623d03 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -12,6 +12,7 @@ POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml' POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml' UPDATE_XML = 'flow_update.xml' +REFRESH_XML = 'flow_refresh.xml' class FlowTests(unittest.TestCase): @@ -113,3 +114,22 @@ def test_populate_permissions(self): TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, }) + + def test_refresh(self): + with open(asset(REFRESH_XML), 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl + '/92967d2d-c7e2-46d0-8847-4802df58f484/run', text=response_xml) + flow_item = TSC.FlowItem('test') + flow_item._id = '92967d2d-c7e2-46d0-8847-4802df58f484' + refresh_job = self.server.flows.refresh(flow_item) + + self.assertEqual(refresh_job.id, 'd1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d') + self.assertEqual(refresh_job.mode, 'Asynchronous') + self.assertEqual(refresh_job.type, 'RunFlow') + self.assertEqual(format_datetime(refresh_job.created_at), '2018-05-22T13:00:29Z') + self.assertIsInstance(refresh_job.flow_run, TSC.FlowRunItem) + self.assertEqual(refresh_job.flow_run.id, 'e0c3067f-2333-4eee-8028-e0a56ca496f6') + self.assertEqual(refresh_job.flow_run.flow_id, '92967d2d-c7e2-46d0-8847-4802df58f484') + self.assertEqual(format_datetime(refresh_job.flow_run.started_at), '2018-05-22T13:00:29Z') + diff --git a/test/test_flowruns.py b/test/test_flowruns.py new file mode 100644 index 000000000..d2e72f31a --- /dev/null +++ b/test/test_flowruns.py @@ -0,0 +1,104 @@ +import unittest +import os +import requests_mock +import xml.etree.ElementTree as ET +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException +from tableauserverclient.server.request_factory import RequestFactory +from ._utils import read_xml_asset, mocked_time + +GET_XML = 'flow_runs_get.xml' +GET_BY_ID_XML = 'flow_runs_get_by_id.xml' +GET_BY_ID_FAILED_XML = 'flow_runs_get_by_id_failed.xml' +GET_BY_ID_INPROGRESS_XML = 'flow_runs_get_by_id_inprogress.xml' + + +class FlowRunTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server.version = "3.10" + + self.baseurl = self.server.flow_runs.baseurl + + def test_get(self): + response_xml = read_xml_asset(GET_XML) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_flow_runs, pagination_item = self.server.flow_runs.get() + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', all_flow_runs[0].id) + self.assertEqual('2021-02-11T01:42:55Z', format_datetime(all_flow_runs[0].started_at)) + self.assertEqual('2021-02-11T01:57:38Z', format_datetime(all_flow_runs[0].completed_at)) + self.assertEqual('Success', all_flow_runs[0].status) + self.assertEqual('100', all_flow_runs[0].progress) + self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flow_runs[0].background_job_id) + + self.assertEqual('a3104526-c0c6-4ea5-8362-e03fc7cbd7ee', all_flow_runs[1].id) + self.assertEqual('2021-02-13T04:05:30Z', format_datetime(all_flow_runs[1].started_at)) + self.assertEqual('2021-02-13T04:05:35Z', format_datetime(all_flow_runs[1].completed_at)) + self.assertEqual('Failed', all_flow_runs[1].status) + self.assertEqual('100', all_flow_runs[1].progress) + self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', all_flow_runs[1].background_job_id) + + def test_get_by_id(self): + response_xml = read_xml_asset(GET_BY_ID_XML) + with requests_mock.mock() as m: + m.get(self.baseurl + "/cc2e652d-4a9b-4476-8c93-b238c45db968", text=response_xml) + flow_run = self.server.flow_runs.get_by_id("cc2e652d-4a9b-4476-8c93-b238c45db968") + + self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', flow_run.id) + self.assertEqual('2021-02-11T01:42:55Z', format_datetime(flow_run.started_at)) + self.assertEqual('2021-02-11T01:57:38Z', format_datetime(flow_run.completed_at)) + self.assertEqual('Success', flow_run.status) + self.assertEqual('100', flow_run.progress) + self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', flow_run.background_job_id) + + def test_cancel_id(self): + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.flow_runs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + def test_cancel_item(self): + run = TSC.FlowRunItem() + run._id = 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760' + with requests_mock.mock() as m: + m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.flow_runs.cancel(run) + + + def test_wait_for_job_finished(self): + # Waiting for an already finished job, directly returns that job's info + response_xml = read_xml_asset(GET_BY_ID_XML) + flow_run_id = 'cc2e652d-4a9b-4476-8c93-b238c45db968' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + flow_run = self.server.flow_runs.wait_for_job(flow_run_id) + + self.assertEqual(flow_run_id, flow_run.id) + self.assertEqual(flow_run.progress, "100") + + + def test_wait_for_job_failed(self): + # Waiting for a failed job raises an exception + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + flow_run_id = 'c2b35d5a-e130-471a-aec8-7bc5435fe0e7' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + with self.assertRaises(FlowRunFailedException): + self.server.flow_runs.wait_for_job(flow_run_id) + + + def test_wait_for_job_timeout(self): + # Waiting for a job which doesn't terminate will throw an exception + response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) + flow_run_id = '71afc22c-9c06-40be-8d0f-4c4166d29e6c' + with mocked_time(), requests_mock.mock() as m: + m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + with self.assertRaises(TimeoutError): + self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) From 46bbe2ede00cc0ae1de383def7e7ca653b36c158 Mon Sep 17 00:00:00 2001 From: mmuttreja-tableau <87720143+mmuttreja-tableau@users.noreply.github.com> Date: Wed, 20 Oct 2021 17:26:45 -0400 Subject: [PATCH 270/567] Update contributors and Changelog for Release 0.17 (#920) * Update CONTRIBUTORS.md & changelog for v 0.17 Update contributors & changelog for v 0.17 --- CHANGELOG.md | 8 ++++++++ CONTRIBUTORS.md | 1 + 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c9197f5..e375f8385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.17.0 (20 October 2021) +Update publish.sh to use python3 (#866) +Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) +Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875) +Updated links to Data Source Methods page in REST API docs (#879) +Upgraded to newer Slack action provider (#880) +Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884) + ## 0.16.0 (15 July 2021) * Documentation updates (#800, #818, #839, #842) * Fixed data alert repr in subscription item (#821) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 74b20d93d..89b8d213c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -59,3 +59,4 @@ The following people have contributed to this project to make it possible, and w * [Dan Zucker](https://github.com/dzucker-tab) * [Brian Cantoni](https://github.com/bcantoni) * [Ovini Nanayakkara](https://github.com/ovinis) +* [Manish Muttreja](https://github.com/mmuttreja-tableau) From a899a959587e2d9f6f027089d814bed00b71bf49 Mon Sep 17 00:00:00 2001 From: mmuttreja-tableau <87720143+mmuttreja-tableau@users.noreply.github.com> Date: Thu, 21 Oct 2021 12:10:37 -0400 Subject: [PATCH 271/567] Adjusting changelog to include missing updates for release 0.17 (#922) --- CHANGELOG.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e375f8385..f5c753cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ ## 0.17.0 (20 October 2021) -Update publish.sh to use python3 (#866) -Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) -Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875) -Updated links to Data Source Methods page in REST API docs (#879) -Upgraded to newer Slack action provider (#880) -Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884) +* Added support for accepting parameters for post request of the metadata api (#850) +* Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) +* Fixed handling for workbooks in personal spaces which do not have projectID or Name (#875) +* Updated links to Data Source Methods page in REST API docs (#879) +* Unified arguments of sample scripts (#889) +* Updated docs for - links to Datasource API (#879) , sample scripts (#892) & metadata query (#896) +* Added support for scheduling DataUpdate Jobs (#891) +* Exposed the fileuploads API endpoint (#894) +* Added a new sample & documentation for metadata API (#895, #896) +* Added support to the package for getting flow run status, as well as the ability to cancel flow runs. (#884) +* Added jobs.wait_for_job method (#903) +* Added description support for datasources item (#912) +* Dropped support for Python 3.5 (#911) ## 0.16.0 (15 July 2021) * Documentation updates (#800, #818, #839, #842) From c37c6c2452a77da159900f25c659b121c0a65650 Mon Sep 17 00:00:00 2001 From: Tyler Doyle Date: Mon, 1 Nov 2021 20:36:17 -0400 Subject: [PATCH 272/567] Fix slack once and for all (#946) The red X keeps coming back so I'd like to mark this as allowably fail-able -- that way the innards of Slack/OAuth/Tableau credentials don't keep polluting test run reports :) (cherry picked from commit c8170ae195e39981d2649bacb2e4682e7a92a73d) --- .github/workflows/slack.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index c3b17e8c4..05671333f 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -4,6 +4,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: + continue-on-error: true runs-on: ubuntu-20.04 name: Sends a message to Slack when a push, a pull request or an issue is made steps: From e4d25c1020eb628b5839b35d219116e50b00e5a3 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 26 Oct 2021 08:04:10 -0700 Subject: [PATCH 273/567] Switch to release Python 3.10 release for CI (#927) * Switch to release Python 3.10 release for CI (cherry picked from commit feed39c2e0d9398d4165c0521c92f4003e874658) --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 61476132f..819cbb902 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-rc.2] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] runs-on: ${{ matrix.os }} From 34ed9b57f0ffd028be35ddbbb81084066fa21c8e Mon Sep 17 00:00:00 2001 From: yoshichan5 Date: Fri, 28 Jan 2022 09:07:02 +0900 Subject: [PATCH 274/567] change distutils to packaging (#977) --- tableauserverclient/server/endpoint/endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 31291abc9..3372afdf1 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -12,7 +12,7 @@ try: from distutils2.version import NormalizedVersion as Version except ImportError: - from distutils.version import LooseVersion as Version + from packaging.version import Version logger = logging.getLogger("tableau.endpoint") From ea5945c4da4522e724cb49d60d4537d307416416 Mon Sep 17 00:00:00 2001 From: TableauKyle <92327461+TableauKyle@users.noreply.github.com> Date: Sat, 12 Mar 2022 19:36:07 -0800 Subject: [PATCH 275/567] Include 'parquet' as a publishing file type (#984) --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c031004e0..18a2f318c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper"] +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] logger = logging.getLogger("tableau.endpoint.datasources") From 4a1656e8ddd61b517ceecbbcebf03800f4e2782c Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 16 Mar 2022 17:03:12 -0700 Subject: [PATCH 276/567] Create feature_request.md (#1005) * Create feature_request.md Add a template to differentiate bug reports and feature requests from users. --- .github/ISSUE_TEMPLATE/feature_request.md | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..b7a7a926d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +title: "[REQUEST TYPE] [FEATURE TITLE]" +about: Suggest a feature that could be added to the client +labels: enhancement, needs investigation +--- + +## Summary +A one line description of the request. Skip this if the title is already a good summary. + + +## Request Type +If you know, say which of these types your request is in the title, and follow the suggestions for that type when writing your description. + +****Type 1: support a REST API:**** +If it is functionality that already exists in the [REST API](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm), example API calls are the clearest way to explain your request. + +****Type 2: add a REST API and support it in tsc.**** +If it is functionality that can be achieved somehow on Tableau Server but not through the REST API, describe the current way to do it. (e.g: functionality that is available in the Web UI, or by using the Hyper API). For UI, screenshots can be helpful. + +****Type 3: new functionality**** +Requests for totally new functionality will generally be passed to the relevant dev team, but we probably can't give any useful estimate of how or when it might be implemented. If it is a feature that is 'about' the API or programmable access, here might be the best place to suggest it, but generally feature requests will be more visible in the [Tableau Community Ideas](https://community.tableau.com/s/ideas) forum and should go there instead. + + +## Description +A clear and concise description of what the feature request is. If you think that the value of this feature might not be obvious, include information like how often it is needed, amount of work saved, etc. If your feature request is related to a file or server in a specific state, describe the starting state when the feature can be used, and the end state after using it. If it involves modifying files, an example file may be helpful. +![](https://img.shields.io/badge/warning-Be%20careful%20not%20to%20post%20user%20names%2C%20passwords%2C%20auth%20tokens%20or%20any%20other%20private%20or%20sensitive%20information-red) + From 9ec60ac573792f8809b7f74a2bae4b04a2f06a1a Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 6 Apr 2022 12:59:59 -0700 Subject: [PATCH 277/567] Release with security and functionality updates (#1021) Switched to using defused_xml for xml attack protection added linting and type hints improve experience with self-signed certificates/invalid ssl updated samples new item types: metrics, revisions for datasources and workbooks features: support adding flows to schedules, exporting workbooks to powerpoint fixes: delete extracts --- .github/workflows/meta-checks.yml | 35 + .github/workflows/publish-pypi.yml | 37 + .github/workflows/run-tests.yml | 9 +- .github/workflows/slack.yml | 1 + CHANGELOG.md | 10 + MANIFEST.in | 7 +- README.md | 4 +- contributing.md | 13 +- samples/add_default_permission.py | 32 +- samples/create_group.py | 29 +- samples/create_project.py | 49 +- samples/create_schedules.py | 129 +- samples/download_view_image.py | 44 +- samples/explore_datasource.py | 44 +- samples/explore_webhooks.py | 35 +- samples/explore_workbook.py | 50 +- samples/export.py | 63 +- samples/export_wb.py | 41 +- samples/extracts.py | 75 ++ samples/filter_sort_groups.py | 76 +- samples/filter_sort_projects.py | 78 +- samples/initialize_server.py | 43 +- samples/kill_all_jobs.py | 27 +- samples/list.py | 50 +- samples/login.py | 82 +- samples/metadata_query.py | 44 +- samples/move_workbook_projects.py | 67 +- samples/move_workbook_sites.py | 55 +- samples/pagination_sample.py | 109 +- samples/publish_datasource.py | 62 +- samples/publish_workbook.py | 56 +- samples/query_permissions.py | 46 +- samples/refresh.py | 33 +- samples/refresh_tasks.py | 37 +- samples/set_http_options.py | 52 - samples/set_refresh_schedule.py | 47 +- samples/update_connection.py | 44 +- samples/update_datasource_data.py | 33 +- setup.cfg | 9 +- setup.py | 7 +- tableauserverclient/__init__.py | 8 +- tableauserverclient/_version.py | 10 +- tableauserverclient/exponential_backoff.py | 12 +- tableauserverclient/models/__init__.py | 20 +- tableauserverclient/models/column_item.py | 4 +- tableauserverclient/models/connection_item.py | 5 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 85 +- tableauserverclient/models/database_item.py | 16 +- tableauserverclient/models/datasource_item.py | 118 +- tableauserverclient/models/dqw_item.py | 13 +- tableauserverclient/models/favorites_item.py | 47 +- tableauserverclient/models/fileupload_item.py | 4 +- tableauserverclient/models/flow_item.py | 59 +- tableauserverclient/models/flow_run_item.py | 70 +- tableauserverclient/models/group_item.py | 58 +- tableauserverclient/models/interval_item.py | 11 +- tableauserverclient/models/job_item.py | 112 +- tableauserverclient/models/metric_item.py | 160 +++ tableauserverclient/models/pagination_item.py | 14 +- .../models/permissions_item.py | 28 +- .../models/personal_access_token_auth.py | 4 +- tableauserverclient/models/project_item.py | 57 +- .../models/property_decorators.py | 1 + tableauserverclient/models/revision_item.py | 82 ++ tableauserverclient/models/schedule_item.py | 67 +- .../models/server_info_item.py | 15 +- tableauserverclient/models/site_item.py | 330 ++--- .../models/subscription_item.py | 36 +- tableauserverclient/models/table_item.py | 6 +- tableauserverclient/models/tableau_auth.py | 8 +- tableauserverclient/models/tag_item.py | 8 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 80 +- tableauserverclient/models/view_item.py | 96 +- tableauserverclient/models/webhook_item.py | 29 +- tableauserverclient/models/workbook_item.py | 105 +- tableauserverclient/namespace.py | 5 +- tableauserverclient/py.typed | 0 tableauserverclient/server/__init__.py | 29 +- .../server/endpoint/__init__.py | 13 +- .../server/endpoint/auth_endpoint.py | 14 +- .../data_acceleration_report_endpoint.py | 7 +- .../server/endpoint/data_alert_endpoint.py | 62 +- .../server/endpoint/databases_endpoint.py | 9 +- .../server/endpoint/datasources_endpoint.py | 210 +++- .../endpoint/default_permissions_endpoint.py | 36 +- .../server/endpoint/dqw_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 28 +- .../server/endpoint/exceptions.py | 10 +- .../server/endpoint/favorites_endpoint.py | 46 +- .../server/endpoint/fileuploads_endpoint.py | 5 +- .../server/endpoint/flow_runs_endpoint.py | 30 +- .../server/endpoint/flows_endpoint.py | 78 +- .../server/endpoint/groups_endpoint.py | 37 +- .../server/endpoint/jobs_endpoint.py | 28 +- .../server/endpoint/metadata_endpoint.py | 6 +- .../server/endpoint/metrics_endpoint.py | 78 ++ .../server/endpoint/permissions_endpoint.py | 22 +- .../server/endpoint/projects_endpoint.py | 37 +- .../server/endpoint/resource_tagger.py | 7 +- .../server/endpoint/schedules_endpoint.py | 133 +- .../server/endpoint/server_info_endpoint.py | 3 +- .../server/endpoint/sites_endpoint.py | 33 +- .../server/endpoint/subscriptions_endpoint.py | 21 +- .../server/endpoint/tables_endpoint.py | 9 +- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 33 +- .../server/endpoint/views_endpoint.py | 62 +- .../server/endpoint/webhooks_endpoint.py | 26 +- .../server/endpoint/workbooks_endpoint.py | 238 +++- tableauserverclient/server/query.py | 68 +- tableauserverclient/server/request_factory.py | 229 ++-- tableauserverclient/server/request_options.py | 1 + tableauserverclient/server/server.py | 27 +- test/_utils.py | 12 +- test/assets/datasource_revision.xml | 14 + test/assets/flow_get_by_id.xml | 10 + test/assets/metrics_get.xml | 33 + test/assets/metrics_get_by_id.xml | 16 + test/assets/metrics_update.xml | 16 + test/assets/populate_excel.xlsx | Bin 0 -> 6623 bytes test/assets/populate_powerpoint.pptx | Bin 0 -> 363648 bytes .../request_option_slicing_queryset.xml | 46 + test/assets/schedule_add_flow.xml | 9 + test/assets/schedule_get_by_id.xml | 4 + test/assets/workbook_revision.xml | 14 + test/test_auth.py | 130 +- test/test_data_acceleration_report.py | 14 +- test/test_dataalert.py | 135 +- test/test_database.py | 109 +- test/test_datasource.py | 771 ++++++------ test/test_datasource_model.py | 2 +- test/test_exponential_backoff.py | 8 +- test/test_favorites.py | 150 ++- test/test_filesys_helpers.py | 50 +- test/test_fileuploads.py | 51 +- test/test_flow.py | 172 +-- test/test_flowruns.py | 102 +- test/test_group.py | 301 ++--- test/test_group_model.py | 1 + test/test_job.py | 82 +- test/test_metadata.py | 92 +- test/test_metrics.py | 105 ++ test/test_pager.py | 60 +- test/test_project.py | 370 +++--- test/test_project_model.py | 1 + test/test_regression_tests.py | 41 +- test/test_request_option.py | 219 ++-- test/test_requests.py | 34 +- test/test_schedule.py | 168 ++- test/test_server_info.py | 54 +- test/test_site.py | 256 ++-- test/test_site_model.py | 1 + test/test_sort.py | 83 +- test/test_subscription.py | 85 +- test/test_table.py | 47 +- test/test_tableauauth_model.py | 10 +- test/test_task.py | 79 +- test/test_user.py | 278 ++--- test/test_user_model.py | 1 + test/test_view.py | 315 ++--- test/test_webhook.py | 64 +- test/test_workbook.py | 1091 ++++++++++------- test/test_workbook_model.py | 1 + 165 files changed, 6657 insertions(+), 4292 deletions(-) create mode 100644 .github/workflows/meta-checks.yml create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 samples/extracts.py delete mode 100644 samples/set_http_options.py create mode 100644 tableauserverclient/models/metric_item.py create mode 100644 tableauserverclient/models/revision_item.py create mode 100644 tableauserverclient/py.typed create mode 100644 tableauserverclient/server/endpoint/metrics_endpoint.py create mode 100644 test/assets/datasource_revision.xml create mode 100644 test/assets/flow_get_by_id.xml create mode 100644 test/assets/metrics_get.xml create mode 100644 test/assets/metrics_get_by_id.xml create mode 100644 test/assets/metrics_update.xml create mode 100644 test/assets/populate_excel.xlsx create mode 100644 test/assets/populate_powerpoint.pptx create mode 100644 test/assets/request_option_slicing_queryset.xml create mode 100644 test/assets/schedule_add_flow.xml create mode 100644 test/assets/schedule_get_by_id.xml create mode 100644 test/assets/workbook_revision.xml create mode 100644 test/test_metrics.py diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml new file mode 100644 index 000000000..7ae27e6b8 --- /dev/null +++ b/.github/workflows/meta-checks.yml @@ -0,0 +1,35 @@ +name: types and style checks + +on: [push, pull_request] + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10'] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Format with black + run: | + black --check --line-length 120 tableauserverclient samples test + + - name: Run Mypy tests + if: always() + run: | + mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 000000000..2b3b8fa3e --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,37 @@ +name: Publish to PyPi + +# This will publish a package to TestPyPi (and real Pypi if run on master) with a version +# number generated by versioneer from the most recent tag looking like v____ +# TODO: maybe move this into the package job so all release-based actions are together +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + build-n-publish: + name: Build dist files for PyPi + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Build dist files + run: | + python -m pip install --upgrade pip + pip install -e .[test] + python setup.py sdist --formats=gztar + - name: Publish distribution 📦 to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to PyPI + if: github.ref == 'refs/heads/master' + uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 819cbb902..9fe99f953 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,6 @@ name: Python tests -on: [push] +on: [push, pull_request] jobs: build: @@ -24,13 +24,12 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] - pip install mypy - name: Test with pytest + if: always() run: | pytest test - - name: Run Mypy but allow failures + - name: Run Mypy tests run: | - mypy --show-error-codes --disable-error-code misc tableauserverclient - continue-on-error: true + mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 05671333f..b11f4009a 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -9,6 +9,7 @@ jobs: name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API + continue-on-error: true uses: archive/github-actions-slack@v2.2.2 id: notify with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c753cdd..c018294d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ + +## 0.18.0 (6 April 2022) +* Switched to using defused_xml for xml attack protection +* added linting and type hints +* improve experience with self-signed certificates/invalid ssl +* updated samples +* new item types: metrics, revisions for datasources and workbooks +* features: support adding flows to schedules, exporting workbooks to powerpoint +* fixes: delete extracts + ## 0.17.0 (20 October 2021) * Added support for accepting parameters for post request of the metadata api (#850) * Fixed jobs.get_by_id(job_id) example & reference docs (#867, #868) diff --git a/MANIFEST.in b/MANIFEST.in index b4b1425f3..c9bb30ee7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,9 +15,6 @@ recursive-include test *.json recursive-include test *.pdf recursive-include test *.png recursive-include test *.py -recursive-include test *.tde -recursive-include test *.tds -recursive-include test *.tdsx -recursive-include test *.twb -recursive-include test *.twbx recursive-include test *.xml +global-include *.pyi +global-include *.typed \ No newline at end of file diff --git a/README.md b/README.md index b454dd4c7..f14c23230 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code and sample files. Python versions 3.6 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. Python versions 3.6 and up are supported. + +To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. For more information on installing and using TSC, see the documentation: diff --git a/contributing.md b/contributing.md index 3d5cd3d43..c5f0fa95e 100644 --- a/contributing.md +++ b/contributing.md @@ -62,14 +62,23 @@ python setup.py build python setup.py test ``` +### To use your locally built version +```shell +pip install . +``` + ### Before Committing Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell # this will run the formatter without making changes -black --line-length 120 tableauserverclient --check +black --line-length 120 tableauserverclient test samples --check # this will format the directory and code for you -black --line-length 120 tableauserverclient +black --line-length 120 tableauserverclient test samples + +# this will run type checking +pip install mypy +mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test ``` diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8018c7b30..56d3afdf1 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -16,16 +16,23 @@ def main(): - parser = argparse.ArgumentParser(description='Add workbook default permissions for a given project.') + parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -53,10 +60,7 @@ def main(): new_capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow} # Each PermissionRule in the list contains a grantee and a dict of capabilities - new_rules = [TSC.PermissionsRule( - grantee=default_permissions.grantee, - capabilities=new_capabilities - )] + new_rules = [TSC.PermissionsRule(grantee=default_permissions.grantee, capabilities=new_capabilities)] new_default_permissions = server.projects.update_workbook_default_permissions(project, new_rules) @@ -78,5 +82,5 @@ def main(): # server.projects.delete(project.id) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/create_group.py b/samples/create_group.py index ad0e6cc4f..16016398d 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -16,16 +16,23 @@ def main(): - parser = argparse.ArgumentParser(description='Creates a sample user group.') + parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -38,10 +45,10 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group = TSC.GroupItem('test') + group = TSC.GroupItem("test") group = server.groups.create(group) print(group) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/create_project.py b/samples/create_project.py index 814d35617..6271f3d93 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -14,27 +14,34 @@ import tableauserverclient as TSC -def create_project(server, project_item): +def create_project(server, project_item, samples=False): try: - project_item = server.projects.create(project_item) - print('Created a new project called: %s' % project_item.name) + project_item = server.projects.create(project_item, samples) + print("Created a new project called: %s" % project_item.name) return project_item except TSC.ServerResponseError: - print('We have already created this project: %s' % project_item.name) + print("We have already created this project: %s" % project_item.name) sys.exit(1) def main(): - parser = argparse.ArgumentParser(description='Create new projects.') + parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -45,23 +52,27 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server) + server.use_server_version() with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name='Top Level Project') + top_level_project = TSC.ProjectItem(name="Top Level Project") top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. - child_project = TSC.ProjectItem(name='Child Project', parent_id=top_level_project.id) - child_project = create_project(server, child_project) + child_project = TSC.ProjectItem(name="Child Project", parent_id=top_level_project.id) + child_project = create_project(server, child_project, samples=True) # Projects can be nested at any level. - grand_child_project = TSC.ProjectItem(name='Grand Child Project', parent_id=child_project.id) + grand_child_project = TSC.ProjectItem(name="Grand Child Project", parent_id=child_project.id) grand_child_project = create_project(server, grand_child_project) + # Projects can be updated + changed_project = server.projects.update(grand_child_project, samples=True) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 39332713b..4fe6db5a4 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -16,17 +16,24 @@ def main(): - parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') - # Options specific to this sample + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: # This sample has no additional options, yet. If you add some, please add them here args = parser.parse_args() @@ -36,47 +43,89 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() with server.auth.sign_in(tableau_auth): # Hourly Schedule # This schedule will run every 2 hours between 2:30AM and 11:00PM - hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), - end_time=time(23, 0), - interval_value=2) - - hourly_schedule = TSC.ScheduleItem("Hourly-Schedule", 50, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) - hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + + hourly_schedule = TSC.ScheduleItem( + "Hourly-Schedule", + 50, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + hourly_interval, + ) + try: + hourly_schedule = server.schedules.create(hourly_schedule) + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + except Exception as e: + print(e) # Daily Schedule # This schedule will run every day at 5AM daily_interval = TSC.DailyInterval(start_time=time(5)) - daily_schedule = TSC.ScheduleItem("Daily-Schedule", 60, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) - daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + daily_schedule = TSC.ScheduleItem( + "Daily-Schedule", + 60, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, + daily_interval, + ) + try: + daily_schedule = server.schedules.create(daily_schedule) + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + except Exception as e: + print(e) # Weekly Schedule # This schedule will wun every Monday, Wednesday, and Friday at 7:15PM - weekly_interval = TSC.WeeklyInterval(time(19, 15), - TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Wednesday, - TSC.IntervalItem.Day.Friday) - weekly_schedule = TSC.ScheduleItem("Weekly-Schedule", 70, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Serial, weekly_interval) - weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + weekly_interval = TSC.WeeklyInterval( + time(19, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday + ) + weekly_schedule = TSC.ScheduleItem( + "Weekly-Schedule", + 70, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, + weekly_interval, + ) + try: + weekly_schedule = server.schedules.create(weekly_schedule) + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + except Exception as e: + print(e) + options = TSC.RequestOptions() + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Weekly Schedule") + ) + schedules, _ = server.schedules.get(req_options=options) + weekly_schedule = schedules[0] + print(weekly_schedule) # Monthly Schedule # This schedule will run on the 15th of every month at 11:30PM - monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), - interval_value=15) - monthly_schedule = TSC.ScheduleItem("Monthly-Schedule", 80, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval) - monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) - - -if __name__ == '__main__': + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + "Monthly-Schedule", + 80, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Parallel, + monthly_interval, + ) + try: + monthly_schedule = server.schedules.create(monthly_schedule) + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + except Exception as e: + print(e) + + # Now fetch the weekly schedule by id + fetched_schedule = server.schedules.get_by_id(weekly_schedule.id) + fetched_interval = fetched_schedule.interval_item + print("Fetched back our weekly schedule, it shows interval ", fetched_interval) + + +if __name__ == "__main__": main() diff --git a/samples/download_view_image.py b/samples/download_view_image.py index 3ac2ed4d5..3b2fbac1c 100644 --- a/samples/download_view_image.py +++ b/samples/download_view_image.py @@ -16,21 +16,27 @@ def main(): - parser = argparse.ArgumentParser(description='Download image of a specified view.') + parser = argparse.ArgumentParser(description="Download image of a specified view.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--view-name', '-vn', required=True, - help='name of view to download an image of') - parser.add_argument('--filepath', '-f', required=True, help='filepath to save the image returned') - parser.add_argument('--maxage', '-m', required=False, help='max age of the image in the cache in minutes.') + parser.add_argument("--view-name", "-vn", required=True, help="name of view to download an image of") + parser.add_argument("--filepath", "-f", required=True, help="filepath to save the image returned") + parser.add_argument("--maxage", "-m", required=False, help="max age of the image in the cache in minutes.") args = parser.parse_args() @@ -44,8 +50,9 @@ def main(): with server.auth.sign_in(tableau_auth): # Step 2: Query for the view that we want an image of req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.view_name)) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.view_name) + ) all_views, pagination_item = server.views.get(req_option) if not all_views: raise LookupError("View with the specified name was not found.") @@ -55,8 +62,9 @@ def main(): if not max_age: max_age = 1 - image_req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, - maxage=max_age) + image_req_option = TSC.ImageRequestOptions( + imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=max_age + ) server.views.populate_image(view_item, image_req_option) with open(args.filepath, "wb") as image_file: @@ -65,5 +73,5 @@ def main(): print("View image saved to {0}".format(args.filepath)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index a78345122..014a274ef 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -17,19 +17,26 @@ def main(): - parser = argparse.ArgumentParser(description='Explore datasource functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--publish', metavar='FILEPATH', help='path to datasource to publish') - parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded datasource') + parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish") + parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource") args = parser.parse_args() @@ -50,7 +57,8 @@ def main(): if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) new_datasource = server.datasources.publish( - new_datasource, args.publish, TSC.Server.PublishMode.Overwrite) + new_datasource, args.publish, TSC.Server.PublishMode.Overwrite + ) print("Datasource published. ID: {}".format(new_datasource.id)) else: print("Publish failed. Could not find the default project.") @@ -67,12 +75,16 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) print("\nConnections for {}: ".format(sample_datasource.name)) - print(["{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections]) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_datasource.connections + ] + ) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) - sample_datasource.tags.update('a', 'b', 'c', 'd') + sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) print("\nOld tag set: {}".format(original_tag_set)) print("New tag set: {}".format(sample_datasource.tags)) @@ -82,5 +94,5 @@ def main(): server.datasources.update(sample_datasource) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 50c677cba..764fb0904 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -18,19 +18,26 @@ def main(): - parser = argparse.ArgumentParser(description='Explore webhook functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--create', help='create a webhook') - parser.add_argument('--delete', help='delete a webhook', action='store_true') + parser.add_argument("--create", help="create a webhook") + parser.add_argument("--delete", help="delete a webhook", action="store_true") args = parser.parse_args() @@ -63,12 +70,12 @@ def main(): # Pick one webhook from the list and delete it sample_webhook = all_webhooks[0] # sample_webhook.delete() - print("+++"+sample_webhook.name) + print("+++" + sample_webhook.name) - if (args.delete): + if args.delete: print("Deleting webhook " + sample_webhook.name) server.webhooks.delete(sample_webhook.id) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 8746db80e..a5a337653 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -18,21 +18,29 @@ def main(): - parser = argparse.ArgumentParser(description='Explore workbook functions supported by the Server API.') + parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--publish', metavar='FILEPATH', help='path to workbook to publish') - parser.add_argument('--download', metavar='FILEPATH', help='path to save downloaded workbook') - parser.add_argument('--preview-image', '-i', metavar='FILENAME', - help='filename (a .png file) to save the preview image') + parser.add_argument("--publish", metavar="FILEPATH", help="path to workbook to publish") + parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded workbook") + parser.add_argument( + "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" + ) args = parser.parse_args() @@ -56,7 +64,7 @@ def main(): new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) print("Workbook published. ID: {}".format(new_workbook.id)) else: - print('Publish failed. Could not find the default project.') + print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() @@ -75,12 +83,16 @@ def main(): # Populate connections server.workbooks.populate_connections(sample_workbook) print("\nConnections for {}: ".format(sample_workbook.name)) - print(["{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections]) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_workbook.connections + ] + ) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) - sample_workbook.tags.update('a', 'b', 'c', 'd') + sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) print("\nWorkbook's old tag set: {}".format(original_tag_set)) @@ -111,10 +123,10 @@ def main(): if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) - with open(args.preview_image, 'wb') as f: + with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/export.py b/samples/export.py index 6317ec53b..701f93fee 100644 --- a/samples/export.py +++ b/samples/export.py @@ -12,29 +12,38 @@ def main(): - parser = argparse.ArgumentParser(description='Export a view as an image, PDF, or CSV') + parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--pdf', dest='type', action='store_const', const=('populate_pdf', 'PDFRequestOptions', 'pdf', - 'pdf')) - group.add_argument('--png', dest='type', action='store_const', const=('populate_image', 'ImageRequestOptions', - 'image', 'png')) - group.add_argument('--csv', dest='type', action='store_const', const=('populate_csv', 'CSVRequestOptions', 'csv', - 'csv')) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) - 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') - parser.add_argument('resource_id', help='LUID for the view') + 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") + parser.add_argument("resource_id", help="LUID for the view") args = parser.parse_args() @@ -45,9 +54,8 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - views = filter(lambda x: x.id == args.resource_id, - TSC.Pager(server.views.get)) - view = views.pop() + views = filter(lambda x: x.id == args.resource_id or x.name == args.resource_id, TSC.Pager(server.views.get)) + view = list(views).pop() # in python 3 filter() returns a filter object # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make @@ -58,21 +66,22 @@ def main(): option_factory = getattr(TSC, option_factory_name) if args.filter: - options = option_factory().vf(*args.filter.split(':')) + options = option_factory().vf(*args.filter.split(":")) else: options = None if args.file: filename = args.file else: - filename = 'out.{}'.format(extension) + filename = "out.{}".format(extension) populate(view, options) - with file(filename, 'wb') as f: - if member_name == 'csv': + with open(filename, "wb") as f: + if member_name == "csv": f.writelines(getattr(view, member_name)) else: f.write(getattr(view, member_name)) + print("saved to " + filename) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/export_wb.py b/samples/export_wb.py index 2be476130..2376ee62b 100644 --- a/samples/export_wb.py +++ b/samples/export_wb.py @@ -16,11 +16,13 @@ import os.path import tableauserverclient as TSC + try: import PyPDF2 except ImportError: - print('Please `pip install PyPDF2` to use this sample') + print("Please `pip install PyPDF2` to use this sample") import sys + sys.exit(1) @@ -34,7 +36,7 @@ def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf logging.info("Exporting {}".format(view.id)) destination_filename = os.path.join(tempdir, view.id) server.views.populate_pdf(view) - with file(destination_filename, 'wb') as f: + with file(destination_filename, "wb") as f: f.write(view.pdf) return destination_filename @@ -50,19 +52,26 @@ def cleanup(tempdir): def main(): - parser = argparse.ArgumentParser(description='Export to PDF all of the views in a workbook.') + parser = argparse.ArgumentParser(description="Export to PDF all of the views in a workbook.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--file', '-f', default='out.pdf', help='filename to store the exported data') - parser.add_argument('resource_id', help='LUID for the workbook') + parser.add_argument("--file", "-f", default="out.pdf", help="filename to store the exported data") + parser.add_argument("resource_id", help="LUID for the workbook") args = parser.parse_args() @@ -70,7 +79,7 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tempdir = tempfile.mkdtemp('tsc') + tempdir = tempfile.mkdtemp("tsc") logging.debug("Saving to tempdir: %s", tempdir) try: @@ -82,11 +91,11 @@ def main(): downloaded = (download(x) for x in get_list(args.resource_id)) output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) - with file(args.file, 'wb') as f: + with file(args.file, "wb") as f: output.write(f) finally: cleanup(tempdir) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/extracts.py b/samples/extracts.py new file mode 100644 index 000000000..e5879a825 --- /dev/null +++ b/samples/extracts.py @@ -0,0 +1,75 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with workbooks. It explores the different +# functions that the Server API supports on workbooks. +# +# With no flags set, this sample will query all workbooks, +# pick one workbook and populate its connections/views, and update +# the workbook. Adding flags will demonstrate the specific feature +# on top of the general operations. +#### + +import argparse +import logging +import os.path + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", help="site name") + parser.add_argument( + "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample + parser.add_argument("--delete") + parser.add_argument("--create") + args = parser.parse_args() + + # 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.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + + # Gets all workbook items + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick one workbook from the list + wb = all_workbooks[3] + + if args.create: + print("create extract on wb ", wb.name) + extract_job = server.workbooks.create_extract(wb, includeAll=True) + print(extract_job) + + if args.delete: + print("delete extract on wb ", wb.name) + jj = server.workbooks.delete_extract(wb) + print(jj) + + +if __name__ == "__main__": + main() diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 24dee791d..e4f2c2bee 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -8,31 +8,39 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC -def create_example_group(group_name='Example Group', server=None): +def create_example_group(group_name="Example Group", server=None): new_group = TSC.GroupItem(group_name) try: new_group = server.groups.create(new_group) - print('Created a new project called: \'%s\'' % group_name) + print("Created a new project called: '%s'" % group_name) print(new_group) except TSC.ServerResponseError: - print('Group \'%s\' already existed' % group_name) + print("Group '%s' already existed" % group_name) def main(): - parser = argparse.ArgumentParser(description='Filter and sort groups.') + parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -46,21 +54,21 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group_name = 'SALES NORTHWEST' + group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" create_example_group(group_name, server) - group_name = 'SALES ROMANIA' + group_name = "SALES ROMANIA" # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) # URL Encode the name of the group that we want to filter on # i.e. turn spaces into plus signs - filter_group_name = 'SALES+ROMANIA' + filter_group_name = urllib.parse.quote_plus(group_name) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - filter_group_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) + ) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list @@ -71,19 +79,37 @@ def main(): error = "No project named '{}' found".format(filter_group_name) print(error) + # Or, try the above with the django style filtering + try: + group = server.groups.filter(name=filter_group_name)[0] + except IndexError: + print(f"No project named '{filter_group_name}' found") + else: + print(group.name) + options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.In, - ['SALES+NORTHWEST', 'SALES+ROMANIA', 'this_group'])) + options.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ) + ) - options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Desc)) + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) matching_groups, pagination_item = server.groups.get(req_options=options) - print('Filtered groups are:') + print("Filtered groups are:") for group in matching_groups: print(group.name) + # or, try the above with the django style filtering. + + groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] + groups = [urllib.parse.quote_plus(group) for group in groups] + for group in server.groups.filter(name__in=groups).sort("-name"): + print(group.name) + -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 23b350fa6..628b1c972 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -7,33 +7,44 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC -def create_example_project(name='Example Project', content_permissions='LockedToProject', - description='Project created for testing', server=None): +def create_example_project( + name="Example Project", + content_permissions="LockedToProject", + description="Project created for testing", + server=None, +): - new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, - description=description) + new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, description=description) try: server.projects.create(new_project) - print('Created a new project called: %s' % name) + print("Created a new project called: %s" % name) except TSC.ServerResponseError: - print('We have already created this resource: %s' % name) + print("We have already created this resource: %s" % name) def main(): - parser = argparse.ArgumentParser(description='Filter and sort projects.') + parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -49,12 +60,12 @@ def main(): # Use highest Server REST API version available server.use_server_version() - filter_project_name = 'default' + filter_project_name = "default" options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - filter_project_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_project_name) + ) filtered_projects, _ = server.projects.get(req_options=options) # Result can either be a matching project or an empty list @@ -65,26 +76,33 @@ def main(): error = "No project named '{}' found".format(filter_project_name) print(error) - create_example_project(name='Example 1', server=server) - create_example_project(name='Example 2', server=server) - create_example_project(name='Example 3', server=server) - create_example_project(name='Proiect ca Exemplu', server=server) + create_example_project(name="Example 1", server=server) + create_example_project(name="Example 2", server=server) + create_example_project(name="Example 3", server=server) + create_example_project(name="Proiect ca Exemplu", server=server) options = TSC.RequestOptions() # don't forget to URL encode the query names - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.In, - ['Example+1', 'Example+2', 'Example+3'])) + options.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["Example+1", "Example+2", "Example+3"] + ) + ) - options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Desc)) + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) matching_projects, pagination_item = server.projects.get(req_options=options) - print('Filtered projects are:') + print("Filtered projects are:") for project in matching_projects: print(project.name, project.id) + # Or, try the django style filtering. + projects = ["Example 1", "Example 2", "Example 3"] + projects = [urllib.parse.quote_plus(p) for p in projects] + for project in server.projects.filter(name__in=projects).sort("-name"): + print(project.name, project.id) + -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/initialize_server.py b/samples/initialize_server.py index a7dd552e1..586011120 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -11,20 +11,27 @@ def main(): - parser = argparse.ArgumentParser(description='Initialize a server with content.') + parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--datasources-folder', '-df', required=True, help='folder containing datasources') - parser.add_argument('--workbooks-folder', '-wf', required=True, help='folder containing workbooks') - parser.add_argument('--project', required=False, default='Default', help='project to use') + parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() @@ -50,8 +57,11 @@ def main(): # Create the site if it doesn't exist if existing_site is None: print("Site not found: {0} Creating it...").format(args.site_id) - new_site = TSC.SiteItem(name=args.site_id, content_url=args.site_id.replace(" ", ""), - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers) + new_site = TSC.SiteItem( + name=args.site_id, + content_url=args.site_id.replace(" ", ""), + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + ) server.sites.create(new_site) else: print("Site {0} exists. Moving on...").format(args.site_id) @@ -70,6 +80,7 @@ def main(): # Step 4: Create the project we need only if it doesn't exist ################################################################################ import time + time.sleep(2) # sad panda...something about eventually consistent model all_projects = TSC.Pager(server_upload.projects) project = next((p for p in all_projects if p.name.lower() == args.project.lower()), None) @@ -90,7 +101,7 @@ def main(): def publish_datasources_to_site(server_object, project, folder): - path = folder + '/*.tds*' + path = folder + "/*.tds*" for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) @@ -99,7 +110,7 @@ def publish_datasources_to_site(server_object, project, folder): def publish_workbooks_to_site(server_object, project, folder): - path = folder + '/*.twb*' + path = folder + "/*.twb*" for fname in glob.glob(path): new_workbook = TSC.WorkbookItem(project.id) diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 196da4b01..02f19d976 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -11,16 +11,23 @@ def main(): - parser = argparse.ArgumentParser(description='Cancel all of the running background jobs.') + parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here @@ -40,5 +47,5 @@ def main(): print(server.jobs.cancel(job.id), job.id, job.status, job.type) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/list.py b/samples/list.py index 867757668..db0b7c790 100644 --- a/samples/list.py +++ b/samples/list.py @@ -13,18 +13,25 @@ def main(): - parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types.') + parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks']) + parser.add_argument("resource_type", choices=["workbook", "datasource", "project", "view", "job", "webhooks"]) args = parser.parse_args() @@ -37,17 +44,24 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources, - 'view': server.views, - 'job': server.jobs, - 'project': server.projects, - 'webhooks': server.webhooks, + "datasource": server.datasources, + "job": server.jobs, + "metric": server.metrics, + "project": server.projects, + "view": server.views, + "webhooks": server.webhooks, + "workbook": server.workbooks, }.get(args.resource_type) - for resource in TSC.Pager(endpoint.get): + options = TSC.RequestOptions() + options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) + + count = 0 + for resource in TSC.Pager(endpoint.get, options): + count = count + 1 print(resource.id, resource.name) + print("Total: {}".format(count)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/login.py b/samples/login.py index c8af97505..c459b9370 100644 --- a/samples/login.py +++ b/samples/login.py @@ -11,49 +11,73 @@ import tableauserverclient as TSC -def main(): - parser = argparse.ArgumentParser(description='Logs in to the server.') - # This command is special, as it doesn't take `token-value` and it offer both token-based and password based authentication. - # Please still try to keep common options like `server` and `site` consistent across samples - # Common options: - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') - # Options specific to this sample - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--username', '-u', help='username to sign into the server') - group.add_argument('--token-name', '-n', help='name of the personal access token used to sign into the server') - +# If a sample has additional arguments, then it should copy this code and insert them after the call to +# sample_define_common_options +# If it has no additional arguments, it can just call this method +def set_up_and_log_in(): + parser = argparse.ArgumentParser(description="Logs in to the server.") + sample_define_common_options(parser) args = parser.parse_args() # Set logging level based on user input, or error by default. logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - # Make sure we use an updated version of the rest apis. - server = TSC.Server(args.server, use_server_version=True) + server = sample_connect_to_server(args) + print(server.server_info.get()) + print(server.server_address, "site:", server.site_id, "user:", server.user_id) + + +def sample_define_common_options(parser): + # Common options; please keep these in sync across all samples by copying or calling this method directly + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-t", help="site name") + auth = parser.add_mutually_exclusive_group(required=True) + auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + auth.add_argument("--username", "-u", help="username to sign into the server") + + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") + parser.add_argument("--password", "-p", help="value of the password used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + +def sample_connect_to_server(args): if args.username: # Trying to authenticate using username and password. - password = getpass.getpass("Password: ") + password = args.password or getpass.getpass("Password: ") - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - with server.auth.sign_in(tableau_auth): - print('Logged in successfully') + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) else: # Trying to authenticate using personal access tokens. - personal_access_token = getpass.getpass("Personal Access Token: ") + token = args.token_value or getpass.getpass("Personal Access Token: ") + + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name=args.token_name, personal_access_token=token, site_id=args.site + ) + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + + if not tableau_auth: + raise TabError("Did not create authentication object. Check arguments.") + + # Only set this to False if you are running against a server you trust AND you know why the cert is broken + check_ssl_certificate = True + + # Make sure we use an updated version of the rest apis, and pass in our cert handling choice + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) + server.auth.sign_in(tableau_auth) + print("Logged in successfully") - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}" - .format(args.server, args.site, args.token_name)) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name=args.token_name, - personal_access_token=personal_access_token, site_id=args.site) - with server.auth.sign_in_with_personal_access_token(tableau_auth): - print('Logged in successfully') + return server -if __name__ == '__main__': - main() +if __name__ == "__main__": + set_up_and_log_in() diff --git a/samples/metadata_query.py b/samples/metadata_query.py index c9cf7394c..65df9ddb0 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -12,19 +12,29 @@ def main(): - parser = argparse.ArgumentParser(description='Use the metadata API to get information on a published data source.') + parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('datasource_name', nargs='?', help="The name of the published datasource. If not present, we query all data sources.") - + parser.add_argument( + "datasource_name", + nargs="?", + help="The name of the published datasource. If not present, we query all data sources.", + ) args = parser.parse_args() @@ -37,7 +47,8 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Execute the query - result = server.metadata.query(""" + result = server.metadata.query( + """ query useMetadataApiToQueryOrdersDatabases($name: String){ publishedDatasources (filter: {name: $name}) { luid @@ -48,17 +59,20 @@ def main(): name } } - }""", {"name": args.datasource_name}) + }""", + {"name": args.datasource_name}, + ) # Display warnings/errors (if any) if result.get("errors"): print("### Errors/Warnings:") pprint(result["errors"]) - + # Print the results if result.get("data"): print("### Results:") pprint(result["data"]["publishedDatasources"]) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index c8227aeda..22465925f 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -9,25 +9,33 @@ import argparse import logging +import urllib.parse import tableauserverclient as TSC def main(): - parser = argparse.ArgumentParser(description='Move one workbook from the default project to another.') + parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-project', '-d', required=True, help='name of project to move workbook into') + parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") + parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") args = parser.parse_args() @@ -39,30 +47,23 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Query workbook to move - req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.workbook_name)) - all_workbooks, pagination_item = server.workbooks.get(req_option) + # Step 2: Find destination project + try: + dest_project = server.projects.filter(name=urllib.parse.quote_plus(args.destination_project))[0] + except IndexError: + raise LookupError(f"No project named {args.destination_project} found.") - # Step 3: Find destination project - all_projects, pagination_item = server.projects.get() - dest_project = next((project for project in all_projects if project.name == args.destination_project), None) + # Step 3: Query workbook to move + try: + workbook = server.workbooks.filter(name=urllib.parse.quote_plus(args.workbook_name))[0] + except IndexError: + raise LookupError(f"No workbook named {args.workbook_name} found") - if dest_project is not None: - # Step 4: Update workbook with new project id - if all_workbooks: - print("Old project: {}".format(all_workbooks[0].project_name)) - all_workbooks[0].project_id = dest_project.id - target_workbook = server.workbooks.update(all_workbooks[0]) - print("New project: {}".format(target_workbook.project_name)) - else: - error = "No workbook named {} found.".format(args.workbook_name) - raise LookupError(error) - else: - error = "No project named {} found.".format(args.destination_project) - raise LookupError(error) + # Step 4: Update workbook with new project id + workbook.project_id = dest_project.id + target_workbook = server.workbooks.update(workbook) + print(f"New project: {target_workbook.project_name}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e0475ac06..c473712e4 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -17,22 +17,30 @@ def main(): - parser = argparse.ArgumentParser(description="Move one workbook from the" - "default project of the default site to" - "the default project of another site.") + parser = argparse.ArgumentParser( + description="Move one workbook from the" + "default project of the default site to" + "the default project of another site." + ) # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--workbook-name', '-w', required=True, help='name of workbook to move') - parser.add_argument('--destination-site', '-d', required=True, help='name of site to move workbook into') - + parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") + parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") args = parser.parse_args() @@ -49,13 +57,14 @@ def main(): with source_server.auth.sign_in(tableau_auth): # Step 2: Query workbook to move req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, args.workbook_name)) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.workbook_name) + ) all_workbooks, pagination_item = source_server.workbooks.get(req_option) # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print('No workbook named {} found.'.format(args.workbook_name)) + print("No workbook named {} found.".format(args.workbook_name)) else: tmpdir = tempfile.mkdtemp() try: @@ -63,8 +72,9 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() - found_destination_site = any((True for site in all_sites if - args.destination_site.lower() == site.content_url.lower())) + found_destination_site = any( + (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + ) if not found_destination_site: error = "No site named {} found.".format(args.destination_site) raise LookupError(error) @@ -78,8 +88,9 @@ def main(): # Step 5: Create a new workbook item and publish workbook. Note that # an empty project_id will publish to the 'Default' project. new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") - new_workbook = dest_server.workbooks.publish(new_workbook, workbook_path, - mode=TSC.Server.PublishMode.Overwrite) + new_workbook = dest_server.workbooks.publish( + new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite + ) print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) # Step 6: Delete workbook from source site and delete temp directory @@ -89,5 +100,5 @@ def main(): shutil.rmtree(tmpdir) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index 2ebd011dc..e194f59f5 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -1,11 +1,12 @@ #### -# This script demonstrates how to use pagination item that is returned as part -# of many of the .get() method calls. +# This script demonstrates how to work with pagination in the .get() method calls, and how to use +# the QuerySet item that is an alternative interface for filtering and sorting these calls. # -# This script will iterate over every workbook that exists on the server using the +# In Part 1, this script will iterate over every workbook that exists on the server using the # pagination item to fetch additional pages as needed. +# In Part 2, the script will iterate over the same workbooks with an easy-to-read filter. # -# While this sample uses workbook, this same technique will work with any of the .get() methods that return +# While this sample uses workbooks, this same technique will work with any of the .get() methods that return # a pagination item #### @@ -18,18 +19,25 @@ def main(): - parser = argparse.ArgumentParser(description='Demonstrate pagination on the list of workbooks on the server.') + parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-n', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') - # Options specific to this sample - # This sample has no additional options, yet. If you add some, please add them here + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # No additional options, yet. If you add some, please add them here args = parser.parse_args() @@ -41,32 +49,61 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Pager returns a generator that yields one item at a time fetching - # from Server only when necessary. Pager takes a server Endpoint as its - # first parameter. It will call 'get' on that endpoint. To get workbooks - # pass `server.workbooks`, to get users pass` server.users`, etc - # You can then loop over the generator to get the objects one at a time - # Here we print the workbook id for each workbook - + you = server.users.get_by_id(server.user_id) + print(you.name, you.id) + # 1. Pager: Pager takes a server Endpoint as its first parameter, and a RequestOptions + # object as the second parameter. The Endpoint defines which type of objects it returns, and + # RequestOptions defines any restrictions on the objects: filter by name, sort, or select a page print("Your server contains the following workbooks:\n") - for wb in TSC.Pager(server.workbooks): + count = 0 + # Using a small number here so that you can see it work. Default is 100 and mostly doesn't need to change + page_options = TSC.RequestOptions(1, 5) + print("Fetching workbooks in pages of 5") + for wb in TSC.Pager(server.workbooks, page_options): + print(wb.name) + count = count + 1 + print("Total: {}\n".format(count)) + + count = 0 + page_options = TSC.RequestOptions(2, 3) + print("Paging: start at the second page of workbooks, using pagesize = 3") + for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) + count = count + 1 + print("Truncated Total: {}\n".format(count)) + + print("Your id: ", you.name, you.id, "\n") + count = 0 + filtered_page_options = TSC.RequestOptions(1, 3) + filter_owner = TSC.Filter("ownerEmail", TSC.RequestOptions.Operator.Equals, "jfitzgerald@tableau.com") + filtered_page_options.filter.add(filter_owner) + print("Fetching workbooks again, filtering by owner") + for wb in TSC.Pager(server.workbooks, filtered_page_options): + print(wb.name, " -- ", wb.owner_id) + count = count + 1 + print("Filtered Total: {}\n".format(count)) - # Pager can also be used in list comprehensions or generator expressions - # for compactness and easy filtering. Generator expressions will use less - # memory than list comprehsnsions. Consult the Python laguage documentation for - # best practices on which are best for your use case. Here we loop over the - # Pager and only keep workbooks where the name starts with the letter 'a' - # >>> [wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')] # List Comprehension - # >>> (wb for wb in TSC.Pager(server.workbooks) if wb.name.startswith('a')) # Generator Expression + # 2. QuerySet offers a fluent interface on top of the RequestOptions object + print("Fetching workbooks again - this time filtered with QuerySet") + count = 0 + page = 1 + more = True + while more: + queryset = server.workbooks.filter(ownerEmail="jfitzgerald@tableau.com") + for wb in queryset.paginate(page_number=page, page_size=3): + print(wb.name, " -- ", wb.owner_id) + count = count + 1 + more = queryset.total_available > count + page = page + 1 + print("QuerySet Total: {}".format(count)) - # Since Pager is a generator it follows the standard conventions and can - # be fed to a list if you really need all the workbooks in memory at once. - # If you need everything, it may be faster to use a larger page size + # 3. QuerySet also allows you to iterate over all objects without explicitly paging. + print("Fetching again - this time without manually paging") + for i, wb in enumerate(server.workbooks.filter(owner_email="jfitzgerald@tableau.com"), start=1): + print(wb.name, "--", wb.owner_id) - # >>> request_options = TSC.RequestOptions(pagesize=1000) - # >>> all_workbooks = list(TSC.Pager(server.workbooks, request_options)) + print(f"QuerySet Total, implicit paging: {i}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8ae744185..ad929fd99 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -25,24 +25,31 @@ def main(): - parser = argparse.ArgumentParser(description='Publish a datasource to server.') + parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--file', '-f', required=True, help='filepath to the datasource to publish') - parser.add_argument('--project', help='Project within which to publish the datasource') - parser.add_argument('--async', '-a', help='Publishing asynchronously', dest='async_', action='store_true') - parser.add_argument('--conn-username', help='connection username') - parser.add_argument('--conn-password', help='connection password') - parser.add_argument('--conn-embed', help='embed connection password to datasource', action='store_true') - parser.add_argument('--conn-oauth', help='connection is configured to use oAuth', action='store_true') + parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--project", help="Project within which to publish the datasource") + parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") + parser.add_argument("--conn-username", help="connection username") + parser.add_argument("--conn-password", help="connection password") + parser.add_argument("--conn-embed", help="embed connection password to datasource", action="store_true") + parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() @@ -64,9 +71,9 @@ def main(): # Retrieve the project id, if a project name was passed if args.project is not None: req_options = TSC.RequestOptions() - req_options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - args.project)) + req_options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) + ) projects = list(TSC.Pager(server.projects, req_options)) if len(projects) > 1: raise ValueError("The project name is not unique") @@ -78,8 +85,9 @@ def main(): # Create a connection_credentials item if connection details are provided new_conn_creds = None if args.conn_username: - new_conn_creds = TSC.ConnectionCredentials(args.conn_username, args.conn_password, - embed=args.conn_embed, oauth=args.conn_oauth) + new_conn_creds = TSC.ConnectionCredentials( + args.conn_username, args.conn_password, embed=args.conn_embed, oauth=args.conn_oauth + ) # Define publish mode - Overwrite, Append, or CreateNew publish_mode = TSC.Server.PublishMode.Overwrite @@ -87,15 +95,17 @@ def main(): # Publish datasource if args.async_: # Async publishing, returns a job_item - new_job = server.datasources.publish(new_datasource, args.filepath, publish_mode, - connection_credentials=new_conn_creds, as_job=True) + new_job = server.datasources.publish( + new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds, as_job=True + ) print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item - new_datasource = server.datasources.publish(new_datasource, args.filepath, publish_mode, - connection_credentials=new_conn_creds) + new_datasource = server.datasources.publish( + new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds + ) print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index fcfcddc15..c553eda0b 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -23,21 +23,27 @@ def main(): - parser = argparse.ArgumentParser(description='Publish a workbook to server.') + parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('--file', '-f', required=True, help='local filepath of the workbook to publish') - parser.add_argument('--as-job', '-a', help='Publishing asynchronously', action='store_true') - parser.add_argument('--skip-connection-check', '-c', help='Skip live connection check', action='store_true') - + parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") + parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") args = parser.parse_args() @@ -72,19 +78,29 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) if args.as_job: - new_job = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job, - skip_connection_check=args.skip_connection_check) + new_job = server.workbooks.publish( + new_workbook, + args.filepath, + overwrite_true, + connections=all_connections, + as_job=args.as_job, + skip_connection_check=args.skip_connection_check, + ) print("Workbook published. JOB ID: {0}".format(new_job.id)) else: - new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true, - connections=all_connections, as_job=args.as_job, - skip_connection_check=args.skip_connection_check) + new_workbook = server.workbooks.publish( + new_workbook, + args.filepath, + overwrite_true, + connections=all_connections, + as_job=args.as_job, + skip_connection_check=args.skip_connection_check, + ) print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 0909f915d..c0d1c3afa 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -13,19 +13,26 @@ def main(): - parser = argparse.ArgumentParser(description='Query permissions of a given resource.') + parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'flow', 'table', 'database']) - parser.add_argument('resource_id') + parser.add_argument("resource_type", choices=["workbook", "datasource", "flow", "table", "database"]) + parser.add_argument("resource_id") args = parser.parse_args() @@ -40,11 +47,11 @@ def main(): # Mapping to grab the handler for the user-inputted resource type endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources, - 'flow': server.flows, - 'table': server.tables, - 'database': server.databases + "workbook": server.workbooks, + "datasource": server.datasources, + "flow": server.flows, + "table": server.tables, + "database": server.databases, }.get(args.resource_type) # Get the resource by its ID @@ -55,8 +62,9 @@ def main(): permissions = resource.permissions # Print result - print("\n{0} permission rule(s) found for {1} {2}." - .format(len(permissions), args.resource_type, args.resource_id)) + print( + "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) + ) for permission in permissions: grantee = permission.grantee @@ -67,5 +75,5 @@ def main(): print("\t{0} - {1}".format(capability, capabilities[capability])) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/refresh.py b/samples/refresh.py index 3eed5b4be..18a7f36e2 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -11,19 +11,26 @@ def main(): - parser = argparse.ArgumentParser(description='Trigger a refresh task on a workbook or datasource.') + parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('resource_type', choices=['workbook', 'datasource']) - parser.add_argument('resource_id') + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") args = parser.parse_args() @@ -46,7 +53,7 @@ def main(): # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done job = server.datasources.refresh(resource) - + print(f"Update job posted (ID: {job.id})") print("Waiting for job...") # `wait_for_job` will throw if the job isn't executed successfully @@ -54,5 +61,5 @@ def main(): print("Job finished succesfully") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index bf69d064a..6ef781544 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -28,28 +28,35 @@ def handle_info(server, args): def main(): - parser = argparse.ArgumentParser(description='Get all of the refresh tasks available on a server') + parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample subcommands = parser.add_subparsers() - list_arguments = subcommands.add_parser('list') + list_arguments = subcommands.add_parser("list") list_arguments.set_defaults(func=handle_list) - run_arguments = subcommands.add_parser('run') - run_arguments.add_argument('id', default=None) + run_arguments = subcommands.add_parser("run") + run_arguments.add_argument("id", default=None) run_arguments.set_defaults(func=handle_run) - info_arguments = subcommands.add_parser('info') - info_arguments.add_argument('id', default=None) + info_arguments = subcommands.add_parser("info") + info_arguments.add_argument("id", default=None) info_arguments.set_defaults(func=handle_info) args = parser.parse_args() @@ -65,5 +72,5 @@ def main(): args.func(server, args) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/set_http_options.py b/samples/set_http_options.py deleted file mode 100644 index 40ed9167e..000000000 --- a/samples/set_http_options.py +++ /dev/null @@ -1,52 +0,0 @@ -#### -# This script demonstrates how to set http options. It will set the option -# to not verify SSL certificate, and query all workbooks on site. -# -# To run the script, you must have installed Python 3.6 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - - parser = argparse.ArgumentParser(description='List workbooks on site, with option set to ignore SSL verification.') - # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') - # Options specific to this sample - # This sample has no additional options, yet. If you add some, please add them here - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - # Step 1: Create required objects for sign in - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server) - - # Step 2: Set http options to disable verifying SSL - server.add_http_options({'verify': False}) - - with server.auth.sign_in(tableau_auth): - - # Step 3: Query all workbooks and list them - all_workbooks, pagination_item = server.workbooks.get() - print('{0} workbooks found. Showing {1}:'.format(pagination_item.total_available, pagination_item.page_size)) - for workbook in all_workbooks: - print('\t{0} (ID: {1})'.format(workbook.name, workbook.id)) - - -if __name__ == '__main__': - main() diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 862ea2372..decdc223f 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -13,21 +13,29 @@ def usage(args): - parser = argparse.ArgumentParser(description='Set refresh schedule for a workbook or datasource.') + parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--workbook', '-w') - group.add_argument('--datasource', '-d') - parser.add_argument('schedule') + group.add_argument("--workbook", "-w") + group.add_argument("--datasource", "-d") + group.add_argument("--flow", "-f") + parser.add_argument("schedule") return parser.parse_args(args) @@ -54,6 +62,13 @@ def get_datasource_by_name(server, name): return datasources.pop() +def get_flow_by_name(server, name): + request_filter = make_filter(Name=name) + flows, _ = server.flows.get(request_filter) + assert len(flows) == 1 + return flows.pop() + + def get_schedule_by_name(server, name): schedules = [x for x in TSC.Pager(server.schedules) if x.name == name] assert len(schedules) == 1 @@ -75,8 +90,13 @@ def run(args): with server.auth.sign_in(tableau_auth): if args.workbook: item = get_workbook_by_name(server, args.workbook) - else: + elif args.datasource: item = get_datasource_by_name(server, args.datasource) + elif args.flow: + item = get_flow_by_name(server, args.flow) + else: + print("A scheduleable item must be included") + return schedule = get_schedule_by_name(server, args.schedule) assign_to_schedule(server, item, schedule) @@ -84,6 +104,7 @@ def run(args): def main(): import sys + args = usage(sys.argv[1:]) run(args) diff --git a/samples/update_connection.py b/samples/update_connection.py index 0e87217e8..44f8ec6c0 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -11,22 +11,29 @@ def main(): - parser = argparse.ArgumentParser(description='Update a connection on a datasource or workbook to embed credentials') + parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('resource_type', choices=['workbook', 'datasource']) - parser.add_argument('resource_id') - parser.add_argument('connection_id') - parser.add_argument('datasource_username') - parser.add_argument('datasource_password') + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("connection_id") + parser.add_argument("datasource_username") + parser.add_argument("datasource_password") args = parser.parse_args() @@ -37,16 +44,13 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - 'workbook': server.workbooks, - 'datasource': server.datasources - }.get(args.resource_type) + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) - assert(len(connections) == 1) + assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username connection.password = args.datasource_password @@ -54,5 +58,5 @@ def main(): print(update_function(resource, connection).__dict__) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 74c8ea6fb..41f42ee74 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -21,18 +21,27 @@ def main(): - parser = argparse.ArgumentParser(description='Delete the `Europe` region from a published `World Indicators` datasource.') + parser = argparse.ArgumentParser( + description="Delete the `Europe` region from a published `World Indicators` datasource." + ) # Common options; please keep those in sync across all samples - parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', help='site name') - parser.add_argument('--token-name', '-p', required=True, - help='name of the personal access token used to sign into the server') - parser.add_argument('--token-value', '-v', required=True, - help='value of the personal access token used to sign into the server') - parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', - help='desired logging level (set to error by default)') + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) # Options specific to this sample - parser.add_argument('datasource_id', help="The LUID of the `World Indicators` datasource") + parser.add_argument("datasource_id", help="The LUID of the `World Indicators` datasource") args = parser.parse_args() @@ -61,7 +70,7 @@ def main(): "action": "delete", "target-table": "Extract", "target-schema": "Extract", - "condition": {"op": "eq", "target-col": "Region", "const": {"type": "string", "v": "Europe"}} + "condition": {"op": "eq", "target-col": "Region", "const": {"type": "string", "v": "Europe"}}, } ] @@ -74,5 +83,5 @@ def main(): print("Job finished succesfully") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/setup.cfg b/setup.cfg index 6136b814a..dafb578b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,6 @@ [wheel] universal = 1 -[pycodestyle] -select = -max_line_length = 120 - [pep8] max_line_length = 120 @@ -14,7 +10,7 @@ max_line_length = 120 [versioneer] VCS = git -style = pep440 +style = pep440-pre versionfile_source = tableauserverclient/_version.py versionfile_build = tableauserverclient/_version.py tag_prefix = v @@ -26,3 +22,6 @@ smoke=pytest [tool:pytest] testpaths = test smoke addopts = --junitxml=./test.junit.xml + +[mypy] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index 8b374f0ce..ae19dcd26 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['mock', 'pycodestyle', 'pytest', 'requests-mock>=1.0,<2.0'] +test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy==0.910'] setup( name='tableauserverclient', @@ -24,6 +24,7 @@ author='Tableau', author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', + package_data={'tableauserverclient':['py.typed']}, packages=['tableauserverclient', 'tableauserverclient.models', 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], license='MIT', @@ -33,10 +34,12 @@ test_suite='test', setup_requires=pytest_runner, install_requires=[ + 'defusedxml>=0.7.1', 'requests>=2.11,<3.0', ], tests_require=test_requirements, extras_require={ 'test': test_requirements - } + }, + zip_safe=False ) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 2ad65d71e..897c69fb0 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,4 +1,4 @@ -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE +from ._version import get_versions from .models import ( ConnectionCredentials, ConnectionItem, @@ -34,8 +34,11 @@ FlowItem, WebhookItem, PersonalAccessTokenAuth, - FlowRunItem + FlowRunItem, + RevisionItem, + MetricItem, ) +from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( RequestOptions, CSVRequestOptions, @@ -49,7 +52,6 @@ NotSignedInError, Pager, ) -from ._version import get_versions __version__ = get_versions()["version"] __VERSION__ = __version__ diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 1737a980a..d47374097 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -51,7 +51,7 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} +LONG_VERSION_PY = {} # type: ignore HANDLERS = {} @@ -120,7 +120,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): return { - "version": dirname[len(parentdir_prefix):], + "version": dirname[len(parentdir_prefix) :], "full-revisionid": None, "dirty": False, "error": None, @@ -187,7 +187,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -204,7 +204,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) return { @@ -304,7 +304,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): tag_prefix, ) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) diff --git a/tableauserverclient/exponential_backoff.py b/tableauserverclient/exponential_backoff.py index 2b3ded109..69ffdc96b 100644 --- a/tableauserverclient/exponential_backoff.py +++ b/tableauserverclient/exponential_backoff.py @@ -1,12 +1,12 @@ import time # Polling for server-side events (such as job completion) uses exponential backoff for the sleep intervals between polls -ASYNC_POLL_MIN_INTERVAL=0.5 -ASYNC_POLL_MAX_INTERVAL=30 -ASYNC_POLL_BACKOFF_FACTOR=1.4 +ASYNC_POLL_MIN_INTERVAL = 0.5 +ASYNC_POLL_MAX_INTERVAL = 30 +ASYNC_POLL_BACKOFF_FACTOR = 1.4 -class ExponentialBackoffTimer(): +class ExponentialBackoffTimer: def __init__(self, *, timeout=None): self.start_time = time.time() self.timeout = timeout @@ -15,7 +15,7 @@ def __init__(self, *, timeout=None): def sleep(self): max_sleep_time = ASYNC_POLL_MAX_INTERVAL if self.timeout is not None: - elapsed = (time.time() - self.start_time) + elapsed = time.time() - self.start_time if elapsed >= self.timeout: raise TimeoutError(f"Timeout after {elapsed} seconds waiting for asynchronous event") remaining_time = self.timeout - elapsed @@ -27,4 +27,4 @@ def sleep(self): max_sleep_time = max(max_sleep_time, ASYNC_POLL_MIN_INTERVAL) time.sleep(min(self.current_sleep_interval, max_sleep_time)) - self.current_sleep_interval *= ASYNC_POLL_BACKOFF_FACTOR \ No newline at end of file + self.current_sleep_interval *= ASYNC_POLL_BACKOFF_FACTOR diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e5945782d..f72878366 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,16 +1,16 @@ +from .column_item import ColumnItem from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem -from .column_item import ColumnItem from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem -from .datasource_item import DatasourceItem from .database_item import DatabaseItem +from .datasource_item import DatasourceItem from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError from .favorites_item import FavoriteItem -from .group_item import GroupItem from .flow_item import FlowItem from .flow_run_item import FlowRunItem +from .group_item import GroupItem from .interval_item import ( IntervalItem, DailyInterval, @@ -19,20 +19,22 @@ HourlyInterval, ) from .job_item import JobItem, BackgroundJobItem +from .metric_item import MetricItem from .pagination_item import PaginationItem +from .permissions_item import PermissionsRule, Permission +from .personal_access_token_auth import PersonalAccessTokenAuth +from .personal_access_token_auth import PersonalAccessTokenAuth from .project_item import ProjectItem +from .revision_item import RevisionItem from .schedule_item import ScheduleItem from .server_info_item import ServerInfoItem from .site_item import SiteItem +from .subscription_item import SubscriptionItem +from .table_item import TableItem from .tableau_auth import TableauAuth -from .personal_access_token_auth import PersonalAccessTokenAuth from .target import Target -from .table_item import TableItem from .task_item import TaskItem from .user_item import UserItem from .view_item import ViewItem -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 +from .workbook_item import WorkbookItem diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index a95d005ca..dbf200d21 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring from .property_decorators import property_not_empty @@ -47,7 +47,7 @@ def _set_values(self, id, name, description, remote_type): @classmethod def from_response(cls, resp, ns): all_column_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) for column_xml in all_column_xml: diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 018c093c7..17ca20bb9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,4 +1,5 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring + from .connection_credentials import ConnectionCredentials @@ -39,7 +40,7 @@ def __repr__(self): @classmethod def from_response(cls, resp, ns): all_connection_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index eab6c24cd..3c1d6ed40 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class DataAccelerationReportItem(object): @@ -70,7 +70,7 @@ def _parse_element(comparison_record_xml, ns): @classmethod def from_response(cls, resp, ns): comparison_records = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_comparison_records_xml = parsed_response.findall(".//t:comparisonRecord", namespaces=ns) for comparison_record_xml in all_comparison_records_xml: ( diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index a4d11ca5e..1455743cd 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,12 +1,15 @@ -import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring from .property_decorators import ( property_not_empty, property_is_enum, property_is_boolean, ) -from .user_item import UserItem -from .view_item import ViewItem + +if TYPE_CHECKING: + from datetime import datetime class DataAlertItem(object): @@ -18,35 +21,35 @@ class Frequency: Weekly = "Weekly" def __init__(self): - self._id = None - self._subject = None - self._creatorId = None - self._createdAt = None - self._updatedAt = None - self._frequency = None - self._public = None - self._owner_id = None - self._owner_name = None - self._view_id = None - self._view_name = None - self._workbook_id = None - self._workbook_name = None - self._project_id = None - self._project_name = None - self._recipients = None - - def __repr__(self): + self._id: Optional[str] = None + self._subject: Optional[str] = None + self._creatorId: Optional[str] = None + self._createdAt: Optional["datetime"] = None + self._updatedAt: Optional["datetime"] = None + self._frequency: Optional[str] = None + self._public: Optional[bool] = None + self._owner_id: Optional[str] = None + self._owner_name: Optional[str] = None + self._view_id: Optional[str] = None + self._view_name: Optional[str] = None + self._workbook_id: Optional[str] = None + self._workbook_name: Optional[str] = None + self._project_id: Optional[str] = None + self._project_name: Optional[str] = None + self._recipients: Optional[List[str]] = None + + def __repr__(self) -> str: return "".format( **self.__dict__ ) @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def subject(self): + def subject(self) -> Optional[str]: return self._subject @subject.setter @@ -55,69 +58,69 @@ def subject(self, value): self._subject = value @property - def frequency(self): + def frequency(self) -> Optional[str]: return self._frequency @frequency.setter @property_is_enum(Frequency) - def frequency(self, value): + def frequency(self, value: str) -> None: self._frequency = value @property - def public(self): + def public(self) -> Optional[bool]: return self._public @public.setter @property_is_boolean - def public(self, value): + def public(self, value: bool) -> None: self._public = value @property - def creatorId(self): + def creatorId(self) -> Optional[str]: return self._creatorId @property - def recipients(self): + def recipients(self) -> List[str]: return self._recipients or list() @property - def createdAt(self): + def createdAt(self) -> Optional["datetime"]: return self._createdAt @property - def updatedAt(self): + def updatedAt(self) -> Optional["datetime"]: return self._updatedAt @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @property - def owner_name(self): + def owner_name(self) -> Optional[str]: return self._owner_name @property - def view_id(self): + def view_id(self) -> Optional[str]: return self._view_id @property - def view_name(self): + def view_name(self) -> Optional[str]: return self._view_name @property - def workbook_id(self): + def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def workbook_name(self): + def workbook_name(self) -> Optional[str]: return self._workbook_name @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name def _set_values( @@ -173,9 +176,9 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["DataAlertItem"]: all_alert_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) for alert_xml in all_alert_xml: diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4934af81b..862a51a11 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,11 +1,11 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring +from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, property_is_boolean, ) -from .exceptions import UnpopulatedPropertyError class DatabaseItem(object): @@ -53,6 +53,11 @@ def dqws(self): def content_permissions(self): return self._content_permissions + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value): + self._content_permissions = value + @property def permissions(self): if self._permissions is None: @@ -67,11 +72,6 @@ def default_table_permissions(self): raise UnpopulatedPropertyError(error) return self._default_table_permissions() - @content_permissions.setter - @property_is_enum(ContentPermissions) - def content_permissions(self, value): - self._content_permissions = value - @property def id(self): return self._id @@ -254,7 +254,7 @@ def _set_data_quality_warnings(self, dqw): @classmethod def from_response(cls, resp, ns): all_database_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_database_xml = parsed_response.findall(".//t:database", namespaces=ns) for database_xml in all_database_xml: diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5b23341d0..c7823918f 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,4 +1,9 @@ +import copy import xml.etree.ElementTree as ET +from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_not_nullable, @@ -7,7 +12,19 @@ ) from .tag_item import TagItem from ..datetime_helpers import parse_datetime -import copy + +if TYPE_CHECKING: + from .permissions_item import PermissionsRule + from .connection_item import ConnectionItem + from .revision_item import RevisionItem + import datetime + +from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .permissions_item import PermissionsRule + from .connection_item import ConnectionItem + import datetime class DatasourceItem(object): @@ -16,79 +33,82 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" - def __init__(self, project_id, name=None): + def __init__(self, project_id: str, name: str = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None self._connections = None - self._content_url = None + self._content_url: Optional[str] = None self._created_at = None self._datasource_type = None self._description = None self._encrypt_extracts = None self._has_extracts = None - self._id = None - self._initial_tags = set() - self._project_name = None + self._id: Optional[str] = None + self._initial_tags: Set = set() + self._project_name: Optional[str] = None + self._revisions = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None self.description = None self.name = name - self.owner_id = None + self.owner_id: Optional[str] = None self.project_id = project_id - self.tags = set() + self.tags: Set[str] = set() self._permissions = None self._data_quality_warnings = None + return None + @property - def ask_data_enablement(self): + def ask_data_enablement(self) -> Optional["DatasourceItem.AskDataEnablement"]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value): + def ask_data_enablement(self, value: Optional["DatasourceItem.AskDataEnablement"]): self._ask_data_enablement = value @property - def connections(self): + def connections(self) -> Optional[List["ConnectionItem"]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self): + def permissions(self) -> Optional[List["PermissionsRule"]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property - def certified(self): + def certified(self) -> Optional[bool]: return self._certified @certified.setter @property_not_nullable @property_is_boolean - def certified(self, value): + def certified(self, value: Optional[bool]): self._certified = value @property - def certification_note(self): + def certification_note(self) -> Optional[str]: return self._certification_note @certification_note.setter - def certification_note(self, value): + def certification_note(self, value: Optional[str]): self._certification_note = value @property @@ -97,7 +117,7 @@ def encrypt_extracts(self): @encrypt_extracts.setter @property_is_boolean - def encrypt_extracts(self, value): + def encrypt_extracts(self, value: Optional[bool]): self._encrypt_extracts = value @property @@ -108,55 +128,62 @@ def dqws(self): return self._data_quality_warnings() @property - def has_extracts(self): + def has_extracts(self) -> Optional[bool]: return self._has_extracts @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def project_id(self): + def project_id(self) -> str: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str): self._project_id = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def datasource_type(self): + def datasource_type(self) -> Optional[str]: return self._datasource_type @property - def description(self): + def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value): + def description(self, value: str): self._description = value @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at @property - def use_remote_query_agent(self): + def use_remote_query_agent(self) -> Optional[bool]: return self._use_remote_query_agent @use_remote_query_agent.setter @property_is_boolean - def use_remote_query_agent(self, value): + def use_remote_query_agent(self, value: bool): self._use_remote_query_agent = value @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url + @property + def revisions(self) -> List["RevisionItem"]: + if self._revisions is None: + error = "Datasource item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections @@ -166,9 +193,12 @@ def _set_permissions(self, permissions): def _set_data_quality_warnings(self, dqws): self._data_quality_warnings = dqws + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_elements(self, datasource_xml, ns): if not isinstance(datasource_xml, ET.Element): - datasource_xml = ET.fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) + datasource_xml = fromstring(datasource_xml).find(".//t:datasource", namespaces=ns) if datasource_xml is not None: ( ask_data_enablement, @@ -271,9 +301,9 @@ def _set_values( self._webpage_url = webpage_url @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) for datasource_xml in all_datasource_xml: @@ -322,16 +352,16 @@ def from_response(cls, resp, ns): return all_datasource_items @staticmethod - def _parse_element(datasource_xml, ns): - id_ = datasource_xml.get('id', None) - name = datasource_xml.get('name', None) - datasource_type = datasource_xml.get('type', None) - description = datasource_xml.get('description', None) - content_url = datasource_xml.get('contentUrl', None) - created_at = parse_datetime(datasource_xml.get('createdAt', None)) - updated_at = parse_datetime(datasource_xml.get('updatedAt', None)) - certification_note = datasource_xml.get('certificationNote', None) - certified = str(datasource_xml.get('isCertified', None)).lower() == 'true' + def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + id_ = datasource_xml.get("id", None) + name = datasource_xml.get("name", None) + datasource_type = datasource_xml.get("type", None) + description = datasource_xml.get("description", None) + content_url = datasource_xml.get("contentUrl", None) + created_at = parse_datetime(datasource_xml.get("createdAt", None)) + updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) + certification_note = datasource_xml.get("certificationNote", None) + certified = str(datasource_xml.get("isCertified", None)).lower() == "true" certification_note = datasource_xml.get("certificationNote", None) certified = str(datasource_xml.get("isCertified", None)).lower() == "true" content_url = datasource_xml.get("contentUrl", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index a7f8ec9cb..2baecee09 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -1,4 +1,5 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring + from ..datetime_helpers import parse_datetime @@ -80,14 +81,6 @@ def severe(self): def severe(self, value): self._severe = value - @property - def active(self): - return self._active - - @active.setter - def active(self, value): - self._active = value - @property def created_at(self): return self._created_at @@ -106,7 +99,7 @@ def updated_at(self, value): @classmethod def from_response(cls, resp, ns): - return cls.from_xml_element(ET.fromstring(resp), ns) + return cls.from_xml_element(fromstring(resp), ns) @classmethod def from_xml_element(cls, parsed_response, ns): diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 3d6feff5d..afa769fd9 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,30 +1,50 @@ -import xml.etree.ElementTree as ET import logging -from .workbook_item import WorkbookItem -from .view_item import ViewItem -from .project_item import ProjectItem + +from defusedxml.ElementTree import fromstring + from .datasource_item import DatasourceItem +from .flow_item import FlowItem +from .project_item import ProjectItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem logger = logging.getLogger("tableau.models.favorites_item") +from typing import Dict, List, Union + +FavoriteType = Dict[ + str, + List[ + Union[ + DatasourceItem, + ProjectItem, + FlowItem, + ViewItem, + WorkbookItem, + ] + ], +] + class FavoriteItem: class Type: - Workbook = "workbook" - Datasource = "datasource" - View = "view" - Project = "project" + Workbook: str = "workbook" + Datasource: str = "datasource" + View: str = "view" + Project: str = "project" + Flow: str = "flow" @classmethod - def from_response(cls, xml, namespace): - favorites = { + def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + favorites: FavoriteType = { "datasources": [], + "flows": [], "projects": [], "views": [], "workbooks": [], } - parsed_response = ET.fromstring(xml) + parsed_response = fromstring(xml) for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace): fav_workbook = WorkbookItem("") fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) @@ -45,5 +65,10 @@ def from_response(cls, xml, namespace): fav_project._set_values(*fav_project._parse_element(project)) if fav_project: favorites["projects"].append(fav_project) + for flow in parsed_response.findall(".//t:favorite/t:flow", namespace): + fav_flow = FlowItem("flows") + fav_flow._set_values(*fav_flow._parse_element(flow, namespace)) + if fav_flow: + favorites["flows"].append(fav_flow) return favorites diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index a697a5aaf..7848b94cf 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class FileuploadItem(object): @@ -16,7 +16,7 @@ def file_size(self): @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) fileupload_elem = parsed_response.find(".//t:fileUpload", namespaces=ns) fileupload_item = cls() fileupload_item._upload_session_id = fileupload_elem.get("uploadSessionId", None) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d1387f368..96a99c943 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,24 +1,31 @@ +import copy import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING, Set + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_nullable from .tag_item import TagItem from ..datetime_helpers import parse_datetime -import copy + +if TYPE_CHECKING: + import datetime class FlowItem(object): - def __init__(self, project_id, name=None): - self._webpage_url = None - self._created_at = None - self._id = None - self._initial_tags = set() - self._project_name = None - self._updated_at = None - self.name = name - self.owner_id = None - self.project_id = project_id - self.tags = set() - self.description = None + def __init__(self, project_id: str, name: Optional[str] = None) -> None: + self._webpage_url: Optional[str] = None + self._created_at: Optional["datetime.datetime"] = None + self._id: Optional[str] = None + self._initial_tags: Set[str] = set() + self._project_name: Optional[str] = None + self._updated_at: Optional["datetime.datetime"] = None + self.name: Optional[str] = name + self.owner_id: Optional[str] = None + self.project_id: str = project_id + self.tags: Set[str] = set() + self.description: Optional[str] = None self._connections = None self._permissions = None @@ -39,11 +46,11 @@ def permissions(self): return self._permissions() @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property @@ -54,36 +61,36 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def project_id(self): + def project_id(self) -> str: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str) -> None: self._project_id = value @property - def description(self): + def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value): + def description(self, value: str) -> None: self._description = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def flow_type(self): + def flow_type(self): # What is this? It doesn't seem to get set anywhere. return self._flow_type @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at def _set_connections(self, connections): @@ -97,7 +104,7 @@ def _set_data_quality_warnings(self, dqws): def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): - flow_xml = ET.fromstring(flow_xml).find(".//t:flow", namespaces=ns) + flow_xml = fromstring(flow_xml).find(".//t:flow", namespaces=ns) if flow_xml is not None: ( _, @@ -161,9 +168,9 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) for flow_xml in all_flow_xml: diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 251c667b1..f6ce3d0d5 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,54 +1,52 @@ -import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime import itertools +from typing import Dict, List, Optional, Type, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime class FlowRunItem(object): def __init__(self) -> None: - self._id=None - self._flow_id=None - self._status=None - self._started_at=None - self._completed_at=None - self._progress=None - self._background_job_id=None - - + self._id: str = "" + self._flow_id: Optional[str] = None + self._status: Optional[str] = None + self._started_at: Optional["datetime"] = None + self._completed_at: Optional["datetime"] = None + self._progress: Optional[str] = None + self._background_job_id: Optional[str] = None + @property - def id(self): + def id(self) -> str: return self._id - @property - def flow_id(self): + def flow_id(self) -> Optional[str]: return self._flow_id - @property - def status(self): + def status(self) -> Optional[str]: return self._status - @property - def started_at(self): + def started_at(self) -> Optional["datetime"]: return self._started_at - @property - def completed_at(self): + def completed_at(self) -> Optional["datetime"]: return self._completed_at - @property - def progress(self): + def progress(self) -> Optional[str]: return self._progress - @property - def background_job_id(self): + def background_job_id(self) -> Optional[str]: return self._background_job_id - def _set_values( self, id, @@ -74,14 +72,13 @@ def _set_values( if background_job_id is not None: self._background_job_id = background_job_id - @classmethod - def from_response(cls, resp, ns): + def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: all_flowrun_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( parsed_response.findall(".//t:flowRun[@id]", namespaces=ns), - parsed_response.findall(".//t:flowRuns[@id]", namespaces=ns) + parsed_response.findall(".//t:flowRuns[@id]", namespaces=ns), ) for flowrun_xml in all_flowrun_xml: @@ -91,16 +88,15 @@ def from_response(cls, resp, ns): all_flowrun_items.append(flowrun_item) return all_flowrun_items - @staticmethod def _parse_element(flowrun_xml, ns): result = {} - result['id'] = flowrun_xml.get("id", None) - result['flow_id'] = flowrun_xml.get("flowId", None) - result['status'] = flowrun_xml.get("status", None) - result['started_at'] = parse_datetime(flowrun_xml.get("startedAt", None)) - result['completed_at'] = parse_datetime(flowrun_xml.get("completedAt", None)) - result['progress'] = flowrun_xml.get("progress", None) - result['background_job_id'] = flowrun_xml.get("backgroundJobId", None) + result["id"] = flowrun_xml.get("id", None) + result["flow_id"] = flowrun_xml.get("flowId", None) + result["status"] = flowrun_xml.get("status", None) + result["started_at"] = parse_datetime(flowrun_xml.get("startedAt", None)) + result["completed_at"] = parse_datetime(flowrun_xml.get("completedAt", None)) + result["progress"] = flowrun_xml.get("progress", None) + result["background_job_id"] = flowrun_xml.get("backgroundJobId", None) return result diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index fdc06604b..6fcf18544 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,83 +1,89 @@ -import xml.etree.ElementTree as ET +from typing import Callable, List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_enum from .reference_item import ResourceReference from .user_item import UserItem +if TYPE_CHECKING: + from ..server import Pager + class GroupItem(object): - tag_name = "group" + tag_name: str = "group" class LicenseMode: - onLogin = "onLogin" - onSync = "onSync" + onLogin: str = "onLogin" + onSync: str = "onSync" - def __init__(self, name=None, domain_name=None): - self._id = None - self._license_mode = None - self._minimum_site_role = None - self._users = None - self.name = name - self.domain_name = domain_name + def __init__(self, name=None, domain_name=None) -> None: + self._id: Optional[str] = None + self._license_mode: Optional[str] = None + self._minimum_site_role: Optional[str] = None + self._users: Optional[Callable[..., "Pager"]] = None + self.name: Optional[str] = name + self.domain_name: Optional[str] = domain_name @property - def domain_name(self): + def domain_name(self) -> Optional[str]: return self._domain_name @domain_name.setter - def domain_name(self, value): + def domain_name(self, value: str) -> None: self._domain_name = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> Optional[str]: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str) -> None: self._name = value @property - def license_mode(self): + def license_mode(self) -> Optional[str]: return self._license_mode @license_mode.setter @property_is_enum(LicenseMode) - def license_mode(self, value): + def license_mode(self, value: str) -> None: self._license_mode = value @property - def minimum_site_role(self): + def minimum_site_role(self) -> Optional[str]: return self._minimum_site_role @minimum_site_role.setter @property_is_enum(UserItem.Roles) - def minimum_site_role(self, value): + def minimum_site_role(self, value: str) -> None: self._minimum_site_role = value @property - def users(self): + def users(self) -> "Pager": if self._users is None: error = "Group must be populated with users first." raise UnpopulatedPropertyError(error) # Each call to `.users` should create a new pager, this just runs the callable return self._users() - def to_reference(self): + def to_reference(self) -> ResourceReference: return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_users(self, users): + def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["GroupItem"]: all_group_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) for group_xml in all_group_xml: name = group_xml.get("name", None) @@ -100,5 +106,5 @@ def from_response(cls, resp, ns): return all_group_items @staticmethod - def as_reference(id_): + def as_reference(id_: str) -> ResourceReference: return ResourceReference(id_, GroupItem.tag_name) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 320e01ef2..cf5e70353 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -84,8 +84,9 @@ def _interval_type_pairs(self): class DailyInterval(object): - def __init__(self, start_time): + def __init__(self, start_time, *interval_values): self.start_time = start_time + self.interval = interval_values @property def _frequency(self): @@ -101,6 +102,14 @@ def start_time(self): def start_time(self, value): self._start_time = value + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, interval): + self._interval = interval + class WeeklyInterval(object): def __init__(self, start_time, *interval_values): diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 8c21b24e6..e05c42e22 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,7 +1,13 @@ -import xml.etree.ElementTree as ET +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .flow_run_item import FlowRunItem from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + import datetime + class JobItem(object): class FinishCode: @@ -9,23 +15,23 @@ class FinishCode: Status codes as documented on https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job """ - Success = 0 - Failed = 1 - Cancelled = 2 + Success: int = 0 + Failed: int = 1 + Cancelled: int = 2 def __init__( self, - id_, - job_type, - progress, - created_at, - started_at=None, - completed_at=None, - finish_code=0, - notes=None, - mode=None, - flow_run=None, + id_: str, + job_type: str, + progress: str, + created_at: "datetime.datetime", + started_at: Optional["datetime.datetime"] = None, + completed_at: Optional["datetime.datetime"] = None, + finish_code: int = 0, + notes: Optional[List[str]] = None, + mode: Optional[str] = None, + flow_run: Optional[FlowRunItem] = None, ): self._id = id_ self._type = job_type @@ -34,48 +40,48 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes = notes or [] + self._notes: List[str] = notes or [] self._mode = mode self._flow_run = flow_run @property - def id(self): + def id(self) -> str: return self._id @property - def type(self): + def type(self) -> str: return self._type @property - def progress(self): + def progress(self) -> str: return self._progress @property - def created_at(self): + def created_at(self) -> "datetime.datetime": return self._created_at @property - def started_at(self): + def started_at(self) -> Optional["datetime.datetime"]: return self._started_at @property - def completed_at(self): + def completed_at(self) -> Optional["datetime.datetime"]: return self._completed_at @property - def finish_code(self): + def finish_code(self) -> int: return self._finish_code @property - def notes(self): + def notes(self) -> List[str]: return self._notes @property - def mode(self): + def mode(self) -> Optional[str]: return self._mode @mode.setter - def mode(self, value): + def mode(self, value: str) -> None: # check for valid data here self._mode = value @@ -94,8 +100,8 @@ def __repr__(self): ) @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls, xml, ns) -> List["JobItem"]: + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) all_tasks = [JobItem._parse_element(x, ns) for x in all_tasks_xml] @@ -136,23 +142,23 @@ def _parse_element(cls, element, ns): class BackgroundJobItem(object): class Status: - Pending = "Pending" - InProgress = "InProgress" - Success = "Success" - Failed = "Failed" - Cancelled = "Cancelled" + Pending: str = "Pending" + InProgress: str = "InProgress" + Success: str = "Success" + Failed: str = "Failed" + Cancelled: str = "Cancelled" def __init__( self, - id_, - created_at, - priority, - job_type, - status, - title=None, - subtitle=None, - started_at=None, - ended_at=None, + id_: str, + created_at: "datetime.datetime", + priority: int, + job_type: str, + status: str, + title: Optional[str] = None, + subtitle: Optional[str] = None, + started_at: Optional["datetime.datetime"] = None, + ended_at: Optional["datetime.datetime"] = None, ): self._id = id_ self._type = job_type @@ -165,50 +171,50 @@ def __init__( self._subtitle = subtitle @property - def id(self): + def id(self) -> str: return self._id @property - def name(self): + def name(self) -> Optional[str]: """For API consistency - all other resource endpoints have a name attribute which is used to display what they are. Alias title as name to allow consistent handling of resources in the list sample.""" return self._title @property - def status(self): + def status(self) -> str: return self._status @property - def type(self): + def type(self) -> str: return self._type @property - def created_at(self): + def created_at(self) -> "datetime.datetime": return self._created_at @property - def started_at(self): + def started_at(self) -> Optional["datetime.datetime"]: return self._started_at @property - def ended_at(self): + def ended_at(self) -> Optional["datetime.datetime"]: return self._ended_at @property - def title(self): + def title(self) -> Optional[str]: return self._title @property - def subtitle(self): + def subtitle(self) -> Optional[str]: return self._subtitle @property - def priority(self): + def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py new file mode 100644 index 000000000..a54d1e30e --- /dev/null +++ b/tableauserverclient/models/metric_item.py @@ -0,0 +1,160 @@ +import xml.etree.ElementTree as ET +from ..datetime_helpers import parse_datetime +from .property_decorators import property_is_boolean, property_is_datetime +from .tag_item import TagItem +from typing import List, Optional, TYPE_CHECKING, Set + +if TYPE_CHECKING: + from datetime import datetime + + +class MetricItem(object): + def __init__(self, name: Optional[str] = None): + self._id: Optional[str] = None + self._name: Optional[str] = name + self._description: Optional[str] = None + self._webpage_url: Optional[str] = None + self._created_at: Optional["datetime"] = None + self._updated_at: Optional["datetime"] = None + self._suspended: Optional[bool] = None + self._project_id: Optional[str] = None + self._project_name: Optional[str] = None + self._owner_id: Optional[str] = None + self._view_id: Optional[str] = None + self._initial_tags: Set[str] = set() + self.tags: Set[str] = set() + + @property + def id(self) -> Optional[str]: + return self._id + + @id.setter + def id(self, value: Optional[str]) -> None: + self._id = value + + @property + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, value: Optional[str]) -> None: + self._name = value + + @property + def description(self) -> Optional[str]: + return self._description + + @description.setter + def description(self, value: Optional[str]) -> None: + self._description = value + + @property + def webpage_url(self) -> Optional[str]: + return self._webpage_url + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @created_at.setter + @property_is_datetime + def created_at(self, value: "datetime") -> None: + self._created_at = value + + @property + def updated_at(self) -> Optional["datetime"]: + return self._updated_at + + @updated_at.setter + @property_is_datetime + def updated_at(self, value: "datetime") -> None: + self._updated_at = value + + @property + def suspended(self) -> Optional[bool]: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @property + def project_id(self) -> Optional[str]: + return self._project_id + + @project_id.setter + def project_id(self, value: Optional[str]) -> None: + self._project_id = value + + @property + def project_name(self) -> Optional[str]: + return self._project_name + + @project_name.setter + def project_name(self, value: Optional[str]) -> None: + self._project_name = value + + @property + def owner_id(self) -> Optional[str]: + return self._owner_id + + @owner_id.setter + def owner_id(self, value: Optional[str]) -> None: + self._owner_id = value + + @property + def view_id(self) -> Optional[str]: + return self._view_id + + @view_id.setter + def view_id(self, value: Optional[str]) -> None: + self._view_id = value + + def __repr__(self): + return "".format(**vars(self)) + + @classmethod + def from_response( + cls, + resp: bytes, + ns, + ) -> List["MetricItem"]: + all_metric_items = list() + parsed_response = ET.fromstring(resp) + all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) + for metric_xml in all_metric_xml: + metric_item = cls() + metric_item._id = metric_xml.get("id", None) + metric_item._name = metric_xml.get("name", None) + metric_item._description = metric_xml.get("description", None) + metric_item._webpage_url = metric_xml.get("webpageUrl", None) + metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) + metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) + metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) + for owner in metric_xml.findall(".//t:owner", namespaces=ns): + metric_item._owner_id = owner.get("id", None) + + for project in metric_xml.findall(".//t:project", namespaces=ns): + metric_item._project_id = project.get("id", None) + metric_item._project_name = project.get("name", None) + + for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): + metric_item._view_id = view.get("id", None) + + tags = set() + tags_elem = metric_xml.find(".//t:tags", namespaces=ns) + if tags_elem is not None: + all_tags = TagItem.from_xml_element(tags_elem, ns) + tags = all_tags + + metric_item.tags = tags + metric_item._initial_tags = tags + + all_metric_items.append(metric_item) + return all_metric_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index df9ca26e6..2cb89dc5e 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class PaginationItem(object): @@ -8,20 +8,20 @@ def __init__(self): self._total_available = None @property - def page_number(self): + def page_number(self) -> int: return self._page_number @property - def page_size(self): + def page_size(self) -> int: return self._page_size @property - def total_available(self): + def total_available(self) -> int: return self._total_available @classmethod - def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + def from_response(cls, resp, ns) -> "PaginationItem": + parsed_response = fromstring(resp) pagination_xml = parsed_response.find("t:pagination", namespaces=ns) pagination_item = cls() if pagination_xml is not None: @@ -31,7 +31,7 @@ def from_response(cls, resp, ns): return pagination_item @classmethod - def from_single_page_list(cls, single_page_list): + def from_single_page_list(cls, single_page_list) -> "PaginationItem": item = cls() item._page_number = 1 item._page_size = len(single_page_list) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 113e8525e..71ca56248 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,12 +1,18 @@ -import xml.etree.ElementTree as ET import logging +import xml.etree.ElementTree as ET -from .exceptions import UnknownGranteeTypeError -from .user_item import UserItem +from defusedxml.ElementTree import fromstring +from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError from .group_item import GroupItem +from .user_item import UserItem logger = logging.getLogger("tableau.models.permissions_item") +from typing import Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .reference_item import ResourceReference + class Permission: class Mode: @@ -42,19 +48,19 @@ class Resource: class PermissionsRule(object): - def __init__(self, grantee, capabilities): + def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @classmethod - def from_response(cls, resp, ns=None): - parsed_response = ET.fromstring(resp) + def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict = {} + capability_dict: Dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -62,7 +68,11 @@ def from_response(cls, resp, ns=None): name = capability_xml.get("name") mode = capability_xml.get("mode") - capability_dict[name] = mode + if name is None or mode is None: + logger.error("Capability was not valid: ", capability_xml) + raise UnpopulatedPropertyError() + else: + capability_dict[name] = mode rule = PermissionsRule(grantee, capability_dict) rules.append(rule) @@ -70,7 +80,7 @@ def from_response(cls, resp, ns=None): return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml, ns): + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> "ResourceReference": """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index a95972164..e1744766d 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -1,8 +1,8 @@ class PersonalAccessTokenAuth(object): - def __init__(self, token_name, personal_access_token, site_id=""): + def __init__(self, token_name, personal_access_token, site_id=None): self.token_name = token_name self.personal_access_token = personal_access_token - self.site_id = site_id + self.site_id = site_id if site_id is not None else "" # Personal Access Tokens doesn't support impersonation. self.user_id_to_impersonate = None diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 3a7d01143..177b3e016 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,24 +1,31 @@ import xml.etree.ElementTree as ET +from typing import List, Optional -from .permissions_item import Permission +from defusedxml.ElementTree import fromstring -from .property_decorators import property_is_enum, property_not_empty from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_is_enum, property_not_empty class ProjectItem(object): class ContentPermissions: - LockedToProject = "LockedToProject" - ManagedByOwner = "ManagedByOwner" - LockedToProjectWithoutNested = "LockedToProjectWithoutNested" - - def __init__(self, name, description=None, content_permissions=None, parent_id=None): + LockedToProject: str = "LockedToProject" + ManagedByOwner: str = "ManagedByOwner" + LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" + + def __init__( + self, + name: str, + description: Optional[str] = None, + content_permissions: Optional[str] = None, + parent_id: Optional[str] = None, + ) -> None: self._content_permissions = None - self._id = None - self.description = description - self.name = name - self.content_permissions = content_permissions - self.parent_id = parent_id + self._id: Optional[str] = None + self.description: Optional[str] = description + self.name: str = name + self.content_permissions: Optional[str] = content_permissions + self.parent_id: Optional[str] = parent_id self._permissions = None self._default_workbook_permissions = None @@ -29,6 +36,11 @@ def __init__(self, name, description=None, content_permissions=None, parent_id=N def content_permissions(self): return self._content_permissions + @content_permissions.setter + @property_is_enum(ContentPermissions) + def content_permissions(self, value: Optional[str]) -> None: + self._content_permissions = value + @property def permissions(self): if self._permissions is None: @@ -57,30 +69,25 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() - @content_permissions.setter - @property_is_enum(ContentPermissions) - def content_permissions(self, value): - self._content_permissions = value - @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str) -> None: self._name = value @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @owner_id.setter - def owner_id(self, value): + def owner_id(self, value: str) -> None: raise NotImplementedError("REST API does not currently support updating project owner.") def is_default(self): @@ -88,7 +95,7 @@ def is_default(self): def _parse_common_tags(self, project_xml, ns): if not isinstance(project_xml, ET.Element): - project_xml = ET.fromstring(project_xml).find(".//t:project", namespaces=ns) + project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) if project_xml is not None: ( @@ -126,9 +133,9 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ea2a62380..2d7e01557 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,7 @@ import datetime import re from functools import wraps + from ..datetime_helpers import parse_datetime diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py new file mode 100644 index 000000000..024d45edd --- /dev/null +++ b/tableauserverclient/models/revision_item.py @@ -0,0 +1,82 @@ +from typing import List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime + + +class RevisionItem(object): + def __init__(self): + self._resource_id: Optional[str] = None + self._resource_name: Optional[str] = None + self._revision_number: Optional[str] = None + self._current: Optional[bool] = None + self._deleted: Optional[bool] = None + self._created_at: Optional["datetime"] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + + @property + def resource_id(self) -> Optional[str]: + return self._resource_id + + @property + def resource_name(self) -> Optional[str]: + return self._resource_name + + @property + def revision_number(self) -> Optional[str]: + return self._revision_number + + @property + def current(self) -> Optional[bool]: + return self._current + + @property + def deleted(self) -> Optional[bool]: + return self._deleted + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @property + def user_id(self) -> Optional[str]: + return self._user_id + + @property + def user_name(self) -> Optional[str]: + return self._user_name + + def __repr__(self): + return ( + "" + ).format(**self.__dict__) + + @classmethod + def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + all_revision_items = list() + parsed_response = fromstring(resp) + all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) + for revision_xml in all_revision_xml: + revision_item = cls() + revision_item._resource_id = resource_item.id + revision_item._resource_name = resource_item.name + revision_item._revision_number = revision_xml.get("revisionNumber", None) + revision_item._current = string_to_bool(revision_xml.get("isCurrent", "")) + revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", "")) + revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None)) + for user in revision_xml.findall(".//t:user", namespaces=ns): + revision_item._user_id = user.get("id", None) + revision_item._user_name = user.get("name", None) + + all_revision_items.append(revision_item) + return all_revision_items + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index f8baf0749..828034d23 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -1,5 +1,8 @@ import xml.etree.ElementTree as ET from datetime import datetime +from typing import Optional, Union + +from defusedxml.ElementTree import fromstring from .interval_item import ( IntervalItem, @@ -15,6 +18,8 @@ ) from ..datetime_helpers import parse_datetime +Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] + class ScheduleItem(object): class Type: @@ -31,86 +36,86 @@ class State: Active = "Active" Suspended = "Suspended" - def __init__(self, name, priority, schedule_type, execution_order, interval_item): - self._created_at = None - self._end_schedule_at = None - self._id = None - self._next_run_at = None - self._state = None - self._updated_at = None - self.interval_item = interval_item - self.execution_order = execution_order - self.name = name - self.priority = priority - self.schedule_type = schedule_type + def __init__(self, name: str, priority: int, schedule_type: str, execution_order: str, interval_item: Interval): + self._created_at: Optional[datetime] = None + self._end_schedule_at: Optional[datetime] = None + self._id: Optional[str] = None + self._next_run_at: Optional[datetime] = None + self._state: Optional[str] = None + self._updated_at: Optional[datetime] = None + self.interval_item: Interval = interval_item + self.execution_order: str = execution_order + self.name: str = name + self.priority: int = priority + self.schedule_type: str = schedule_type def __repr__(self): - return ''.format(**self.__dict__) + return ''.format(**vars(self)) @property - def created_at(self): + def created_at(self) -> Optional[datetime]: return self._created_at @property - def end_schedule_at(self): + def end_schedule_at(self) -> Optional[datetime]: return self._end_schedule_at @property - def execution_order(self): + def execution_order(self) -> str: return self._execution_order @execution_order.setter @property_is_enum(ExecutionOrder) - def execution_order(self, value): + def execution_order(self, value: str): self._execution_order = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_nullable - def name(self, value): + def name(self, value: str): self._name = value @property - def next_run_at(self): + def next_run_at(self) -> Optional[datetime]: return self._next_run_at @property - def priority(self): + def priority(self) -> int: return self._priority @priority.setter @property_is_int(range=(1, 100)) - def priority(self, value): + def priority(self, value: int): self._priority = value @property - def schedule_type(self): + def schedule_type(self) -> str: return self._schedule_type @schedule_type.setter @property_is_enum(Type) @property_not_nullable - def schedule_type(self, value): + def schedule_type(self, value: str): self._schedule_type = value @property - def state(self): + def state(self) -> Optional[str]: return self._state @state.setter @property_is_enum(State) - def state(self, value): + def state(self, value: str): self._state = value @property - def updated_at(self): + def updated_at(self) -> Optional[datetime]: return self._updated_at @property @@ -119,7 +124,7 @@ def warnings(self): def _parse_common_tags(self, schedule_xml, ns): if not isinstance(schedule_xml, ET.Element): - schedule_xml = ET.fromstring(schedule_xml).find(".//t:schedule", namespaces=ns) + schedule_xml = fromstring(schedule_xml).find(".//t:schedule", namespaces=ns) if schedule_xml is not None: ( _, @@ -193,7 +198,7 @@ def _set_values( @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) return cls.from_element(parsed_response, ns) @classmethod @@ -308,7 +313,7 @@ def _parse_element(schedule_xml, ns): @staticmethod def parse_add_to_schedule_response(response, ns): - parsed_response = ET.fromstring(response.content) + parsed_response = fromstring(response.content) warnings = ScheduleItem._read_warnings(parsed_response, ns) all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 1f6604662..d0ac5d292 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class ServerInfoItem(object): @@ -7,6 +7,17 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version + def __str__(self): + return ( + "ServerInfoItem: [product version: " + + self._product_version + + ", build no.:" + + self._build_number + + ", REST API version:" + + self.rest_api_version + + "]" + ) + @property def product_version(self): return self._product_version @@ -21,7 +32,7 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index ab0211414..2d27acabf 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,4 +1,8 @@ +import warnings import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + from .property_decorators import ( property_is_enum, property_is_boolean, @@ -8,74 +12,80 @@ property_is_int, ) - VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" +from typing import List, Optional, Union + class SiteItem(object): + _user_quota: Optional[int] = None + _tier_creator_capacity: Optional[int] = None + _tier_explorer_capacity: Optional[int] = None + _tier_viewer_capacity: Optional[int] = None + class AdminMode: - ContentAndUsers = "ContentAndUsers" - ContentOnly = "ContentOnly" + ContentAndUsers: str = "ContentAndUsers" + ContentOnly: str = "ContentOnly" class State: - Active = "Active" - Suspended = "Suspended" + Active: str = "Active" + Suspended: str = "Suspended" def __init__( self, - name, - content_url, - admin_mode=None, - user_quota=None, - storage_quota=None, - disable_subscriptions=False, - subscribe_others_enabled=True, - revision_history_enabled=False, - revision_limit=25, - data_acceleration_mode=None, - flows_enabled=True, - cataloging_enabled=True, - editing_flows_enabled=True, - scheduling_flows_enabled=True, - allow_subscription_attachments=True, - guest_access_enabled=False, - cache_warmup_enabled=True, - commenting_enabled=True, - extract_encryption_mode=None, - request_access_enabled=False, - run_now_enabled=True, - tier_explorer_capacity=None, - tier_creator_capacity=None, - tier_viewer_capacity=None, - data_alerts_enabled=True, - commenting_mentions_enabled=True, - catalog_obfuscation_enabled=True, - flow_auto_save_enabled=True, - web_extraction_enabled=True, - metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, - authoring_enabled=True, - custom_subscription_email_enabled=False, - custom_subscription_email=False, - custom_subscription_footer_enabled=False, - custom_subscription_footer=False, - ask_data_mode="EnabledByDefault", - named_sharing_enabled=True, - mobile_biometrics_enabled=False, - sheet_image_enabled=True, - derived_permissions_enabled=False, - user_visibility_mode="FULL", - use_default_time_zone=True, + name: str, + content_url: str, + admin_mode: str = None, + user_quota: int = None, + storage_quota: int = None, + disable_subscriptions: bool = False, + subscribe_others_enabled: bool = True, + revision_history_enabled: bool = False, + revision_limit: int = 25, + data_acceleration_mode: Optional[str] = None, + flows_enabled: bool = True, + cataloging_enabled: bool = True, + editing_flows_enabled: bool = True, + scheduling_flows_enabled: bool = True, + allow_subscription_attachments: bool = True, + guest_access_enabled: bool = False, + cache_warmup_enabled: bool = True, + commenting_enabled: bool = True, + extract_encryption_mode: Optional[str] = None, + request_access_enabled: bool = False, + run_now_enabled: bool = True, + tier_explorer_capacity: Optional[int] = None, + tier_creator_capacity: Optional[int] = None, + tier_viewer_capacity: Optional[int] = None, + data_alerts_enabled: bool = True, + commenting_mentions_enabled: bool = True, + catalog_obfuscation_enabled: bool = True, + flow_auto_save_enabled: bool = True, + web_extraction_enabled: bool = True, + metrics_content_type_enabled: bool = True, + notify_site_admins_on_throttle: bool = False, + authoring_enabled: bool = True, + custom_subscription_email_enabled: bool = False, + custom_subscription_email: Union[str, bool] = False, + custom_subscription_footer_enabled: bool = False, + custom_subscription_footer: Union[str, bool] = False, + ask_data_mode: str = "EnabledByDefault", + named_sharing_enabled: bool = True, + mobile_biometrics_enabled: bool = False, + sheet_image_enabled: bool = True, + derived_permissions_enabled: bool = False, + user_visibility_mode: str = "FULL", + use_default_time_zone: bool = True, time_zone=None, - auto_suspend_refresh_enabled=True, - auto_suspend_refresh_inactivity_window=30, + auto_suspend_refresh_enabled: bool = True, + auto_suspend_refresh_inactivity_window: int = 30, ): self._admin_mode = None - self._id = None + self._id: Optional[str] = None self._num_users = None self._state = None self._status_reason = None - self._storage = None + self._storage: Optional[str] = None self.user_quota = user_quota self.storage_quota = storage_quota self.content_url = content_url @@ -124,16 +134,16 @@ def __init__( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @property - def admin_mode(self): + def admin_mode(self) -> Optional[str]: return self._admin_mode @admin_mode.setter @property_is_enum(AdminMode) - def admin_mode(self, value): + def admin_mode(self, value: Optional[str]) -> None: self._admin_mode = value @property - def content_url(self): + def content_url(self) -> str: return self._content_url @content_url.setter @@ -142,29 +152,29 @@ def content_url(self): VALID_CONTENT_URL_RE, "content_url can contain only letters, numbers, dashes, and underscores", ) - def content_url(self, value): + def content_url(self, value: str) -> None: self._content_url = value @property - def disable_subscriptions(self): + def disable_subscriptions(self) -> bool: return self._disable_subscriptions @disable_subscriptions.setter @property_is_boolean - def disable_subscriptions(self, value): + def disable_subscriptions(self, value: bool): self._disable_subscriptions = value @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def name(self): + def name(self) -> str: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str): self._name = value @property @@ -172,337 +182,357 @@ def num_users(self): return self._num_users @property - def revision_history_enabled(self): + def revision_history_enabled(self) -> bool: return self._revision_history_enabled @revision_history_enabled.setter @property_is_boolean - def revision_history_enabled(self, value): + def revision_history_enabled(self, value: bool): self._revision_history_enabled = value @property - def revision_limit(self): + def revision_limit(self) -> int: return self._revision_limit @revision_limit.setter @property_is_int((2, 10000), allowed=[-1]) - def revision_limit(self, value): + def revision_limit(self, value: int): self._revision_limit = value @property - def state(self): + def state(self) -> Optional[str]: return self._state @state.setter @property_is_enum(State) - def state(self, value): + def state(self, value: Optional[str]) -> None: self._state = value @property - def status_reason(self): + def status_reason(self) -> Optional[str]: return self._status_reason @property - def storage(self): + def storage(self) -> Optional[str]: return self._storage @property - def subscribe_others_enabled(self): + def user_quota(self) -> Optional[int]: + if any((self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity)): + warnings.warn("Tiered license level is set. Returning None for user_quota") + return None + else: + return self._user_quota + + @user_quota.setter + def user_quota(self, value: Optional[int]) -> None: + if value is not None and any( + (self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity) + ): + raise ValueError( + "User quota conflicts with setting tiered license levels. " + "Use replace_license_tiers_with_user_quota to set those to None, " + "and set user_quota to the desired value." + ) + self._user_quota = value + + @property + def subscribe_others_enabled(self) -> bool: return self._subscribe_others_enabled @subscribe_others_enabled.setter @property_is_boolean - def subscribe_others_enabled(self, value): + def subscribe_others_enabled(self, value: bool) -> None: self._subscribe_others_enabled = value @property - def data_acceleration_mode(self): + def data_acceleration_mode(self) -> Optional[str]: return self._data_acceleration_mode @data_acceleration_mode.setter - def data_acceleration_mode(self, value): + def data_acceleration_mode(self, value: Optional[str]): self._data_acceleration_mode = value @property - def cataloging_enabled(self): + def cataloging_enabled(self) -> bool: return self._cataloging_enabled @cataloging_enabled.setter - def cataloging_enabled(self, value): + def cataloging_enabled(self, value: bool): self._cataloging_enabled = value @property - def flows_enabled(self): + def flows_enabled(self) -> bool: return self._flows_enabled @flows_enabled.setter @property_is_boolean - def flows_enabled(self, value): + def flows_enabled(self, value: bool) -> None: self._flows_enabled = value - def is_default(self): + def is_default(self) -> bool: return self.name.lower() == "default" @property - def editing_flows_enabled(self): + def editing_flows_enabled(self) -> bool: return self._editing_flows_enabled @editing_flows_enabled.setter @property_is_boolean - def editing_flows_enabled(self, value): + def editing_flows_enabled(self, value: bool) -> None: self._editing_flows_enabled = value @property - def scheduling_flows_enabled(self): + def scheduling_flows_enabled(self) -> bool: return self._scheduling_flows_enabled @scheduling_flows_enabled.setter @property_is_boolean - def scheduling_flows_enabled(self, value): + def scheduling_flows_enabled(self, value: bool): self._scheduling_flows_enabled = value @property - def allow_subscription_attachments(self): + def allow_subscription_attachments(self) -> bool: return self._allow_subscription_attachments @allow_subscription_attachments.setter @property_is_boolean - def allow_subscription_attachments(self, value): + def allow_subscription_attachments(self, value: bool): self._allow_subscription_attachments = value @property - def guest_access_enabled(self): + def guest_access_enabled(self) -> bool: return self._guest_access_enabled @guest_access_enabled.setter @property_is_boolean - def guest_access_enabled(self, value): + def guest_access_enabled(self, value: bool) -> None: self._guest_access_enabled = value @property - def cache_warmup_enabled(self): + def cache_warmup_enabled(self) -> bool: return self._cache_warmup_enabled @cache_warmup_enabled.setter @property_is_boolean - def cache_warmup_enabled(self, value): + def cache_warmup_enabled(self, value: bool): self._cache_warmup_enabled = value @property - def commenting_enabled(self): + def commenting_enabled(self) -> bool: return self._commenting_enabled @commenting_enabled.setter @property_is_boolean - def commenting_enabled(self, value): + def commenting_enabled(self, value: bool): self._commenting_enabled = value @property - def extract_encryption_mode(self): + def extract_encryption_mode(self) -> Optional[str]: return self._extract_encryption_mode @extract_encryption_mode.setter - def extract_encryption_mode(self, value): + def extract_encryption_mode(self, value: Optional[str]): self._extract_encryption_mode = value @property - def request_access_enabled(self): + def request_access_enabled(self) -> bool: return self._request_access_enabled @request_access_enabled.setter @property_is_boolean - def request_access_enabled(self, value): + def request_access_enabled(self, value: bool) -> None: self._request_access_enabled = value @property - def run_now_enabled(self): + def run_now_enabled(self) -> bool: return self._run_now_enabled @run_now_enabled.setter @property_is_boolean - def run_now_enabled(self, value): + def run_now_enabled(self, value: bool): self._run_now_enabled = value @property - def tier_explorer_capacity(self): + def tier_explorer_capacity(self) -> Optional[int]: return self._tier_explorer_capacity @tier_explorer_capacity.setter - def tier_explorer_capacity(self, value): + def tier_explorer_capacity(self, value: Optional[int]) -> None: self._tier_explorer_capacity = value @property - def tier_creator_capacity(self): + def tier_creator_capacity(self) -> Optional[int]: return self._tier_creator_capacity @tier_creator_capacity.setter - def tier_creator_capacity(self, value): + def tier_creator_capacity(self, value: Optional[int]) -> None: self._tier_creator_capacity = value @property - def tier_viewer_capacity(self): + def tier_viewer_capacity(self) -> Optional[int]: return self._tier_viewer_capacity @tier_viewer_capacity.setter - def tier_viewer_capacity(self, value): + def tier_viewer_capacity(self, value: Optional[int]): self._tier_viewer_capacity = value @property - def data_alerts_enabled(self): + def data_alerts_enabled(self) -> bool: return self._data_alerts_enabled @data_alerts_enabled.setter @property_is_boolean - def data_alerts_enabled(self, value): + def data_alerts_enabled(self, value: bool) -> None: self._data_alerts_enabled = value @property - def commenting_mentions_enabled(self): + def commenting_mentions_enabled(self) -> bool: return self._commenting_mentions_enabled @commenting_mentions_enabled.setter @property_is_boolean - def commenting_mentions_enabled(self, value): + def commenting_mentions_enabled(self, value: bool) -> None: self._commenting_mentions_enabled = value @property - def catalog_obfuscation_enabled(self): + def catalog_obfuscation_enabled(self) -> bool: return self._catalog_obfuscation_enabled @catalog_obfuscation_enabled.setter @property_is_boolean - def catalog_obfuscation_enabled(self, value): + def catalog_obfuscation_enabled(self, value: bool) -> None: self._catalog_obfuscation_enabled = value @property - def flow_auto_save_enabled(self): + def flow_auto_save_enabled(self) -> bool: return self._flow_auto_save_enabled @flow_auto_save_enabled.setter @property_is_boolean - def flow_auto_save_enabled(self, value): + def flow_auto_save_enabled(self, value: bool) -> None: self._flow_auto_save_enabled = value @property - def web_extraction_enabled(self): + def web_extraction_enabled(self) -> bool: return self._web_extraction_enabled @web_extraction_enabled.setter @property_is_boolean - def web_extraction_enabled(self, value): + def web_extraction_enabled(self, value: bool) -> None: self._web_extraction_enabled = value @property - def metrics_content_type_enabled(self): + def metrics_content_type_enabled(self) -> bool: return self._metrics_content_type_enabled @metrics_content_type_enabled.setter @property_is_boolean - def metrics_content_type_enabled(self, value): + def metrics_content_type_enabled(self, value: bool) -> None: self._metrics_content_type_enabled = value @property - def notify_site_admins_on_throttle(self): + def notify_site_admins_on_throttle(self) -> bool: return self._notify_site_admins_on_throttle @notify_site_admins_on_throttle.setter @property_is_boolean - def notify_site_admins_on_throttle(self, value): + def notify_site_admins_on_throttle(self, value: bool) -> None: self._notify_site_admins_on_throttle = value @property - def authoring_enabled(self): + def authoring_enabled(self) -> bool: return self._authoring_enabled @authoring_enabled.setter @property_is_boolean - def authoring_enabled(self, value): + def authoring_enabled(self, value: bool) -> None: self._authoring_enabled = value @property - def custom_subscription_email_enabled(self): + def custom_subscription_email_enabled(self) -> bool: return self._custom_subscription_email_enabled @custom_subscription_email_enabled.setter @property_is_boolean - def custom_subscription_email_enabled(self, value): + def custom_subscription_email_enabled(self, value: bool) -> None: self._custom_subscription_email_enabled = value @property - def custom_subscription_email(self): + def custom_subscription_email(self) -> Union[str, bool]: return self._custom_subscription_email @custom_subscription_email.setter - def custom_subscription_email(self, value): + def custom_subscription_email(self, value: Union[str, bool]): self._custom_subscription_email = value @property - def custom_subscription_footer_enabled(self): + def custom_subscription_footer_enabled(self) -> bool: return self._custom_subscription_footer_enabled @custom_subscription_footer_enabled.setter @property_is_boolean - def custom_subscription_footer_enabled(self, value): + def custom_subscription_footer_enabled(self, value: bool) -> None: self._custom_subscription_footer_enabled = value @property - def custom_subscription_footer(self): + def custom_subscription_footer(self) -> Union[str, bool]: return self._custom_subscription_footer @custom_subscription_footer.setter - def custom_subscription_footer(self, value): + def custom_subscription_footer(self, value: Union[str, bool]) -> None: self._custom_subscription_footer = value @property - def ask_data_mode(self): + def ask_data_mode(self) -> str: return self._ask_data_mode @ask_data_mode.setter - def ask_data_mode(self, value): + def ask_data_mode(self, value: str) -> None: self._ask_data_mode = value @property - def named_sharing_enabled(self): + def named_sharing_enabled(self) -> bool: return self._named_sharing_enabled @named_sharing_enabled.setter @property_is_boolean - def named_sharing_enabled(self, value): + def named_sharing_enabled(self, value: bool) -> None: self._named_sharing_enabled = value @property - def mobile_biometrics_enabled(self): + def mobile_biometrics_enabled(self) -> bool: return self._mobile_biometrics_enabled @mobile_biometrics_enabled.setter @property_is_boolean - def mobile_biometrics_enabled(self, value): + def mobile_biometrics_enabled(self, value: bool) -> None: self._mobile_biometrics_enabled = value @property - def sheet_image_enabled(self): + def sheet_image_enabled(self) -> bool: return self._sheet_image_enabled @sheet_image_enabled.setter @property_is_boolean - def sheet_image_enabled(self, value): + def sheet_image_enabled(self, value: bool) -> None: self._sheet_image_enabled = value @property - def derived_permissions_enabled(self): + def derived_permissions_enabled(self) -> bool: return self._derived_permissions_enabled @derived_permissions_enabled.setter @property_is_boolean - def derived_permissions_enabled(self, value): + def derived_permissions_enabled(self, value: bool) -> None: self._derived_permissions_enabled = value @property - def user_visibility_mode(self): + def user_visibility_mode(self) -> str: return self._user_visibility_mode @user_visibility_mode.setter - def user_visibility_mode(self, value): + def user_visibility_mode(self, value: str): self._user_visibility_mode = value @property @@ -530,16 +560,22 @@ def auto_suspend_refresh_inactivity_window(self, value): self._auto_suspend_refresh_inactivity_window = value @property - def auto_suspend_refresh_enabled(self): + def auto_suspend_refresh_enabled(self) -> bool: return self._auto_suspend_refresh_enabled @auto_suspend_refresh_enabled.setter - def auto_suspend_refresh_enabled(self, value): + def auto_suspend_refresh_enabled(self, value: bool): self._auto_suspend_refresh_enabled = value + def replace_license_tiers_with_user_quota(self, value: int) -> None: + self.tier_creator_capacity = None + self.tier_explorer_capacity = None + self.tier_viewer_capacity = None + self.user_quota = value + def _parse_common_tags(self, site_xml, ns): if not isinstance(site_xml, ET.Element): - site_xml = ET.fromstring(site_xml).find(".//t:site", namespaces=ns) + site_xml = fromstring(site_xml).find(".//t:site", namespaces=ns) if site_xml is not None: ( _, @@ -723,7 +759,11 @@ def _set_values( if revision_history_enabled is not None: self._revision_history_enabled = revision_history_enabled if user_quota: - self.user_quota = user_quota + try: + self.user_quota = user_quota + except ValueError: + warnings.warn("Tiered license level is set. Setting user_quota to None.") + self.user_quota = None if storage_quota: self.storage_quota = storage_quota if revision_limit: @@ -808,9 +848,9 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["SiteItem"]: all_site_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) for site_xml in all_site_xml: ( @@ -1058,5 +1098,5 @@ def _parse_element(site_xml, ns): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index bc431ed77..e18adc6ae 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,10 +1,16 @@ -import xml.etree.ElementTree as ET -from .target import Target +from typing import List, Type, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .property_decorators import property_is_boolean +from .target import Target + +if TYPE_CHECKING: + from .target import Target class SubscriptionItem(object): - def __init__(self, subject, schedule_id, user_id, target): + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True self.attach_pdf = False @@ -18,7 +24,7 @@ def __init__(self, subject, schedule_id, user_id, target): self.target = target self.user_id = user_id - def __repr__(self): + def __repr__(self) -> str: if self.id is not None: return " bool: return self._attach_image @attach_image.setter @property_is_boolean - def attach_image(self, value): + def attach_image(self, value: bool): self._attach_image = value @property - def attach_pdf(self): + def attach_pdf(self) -> bool: return self._attach_pdf @attach_pdf.setter @property_is_boolean - def attach_pdf(self, value): + def attach_pdf(self, value: bool) -> None: self._attach_pdf = value @property - def send_if_view_empty(self): + def send_if_view_empty(self) -> bool: return self._send_if_view_empty @send_if_view_empty.setter @property_is_boolean - def send_if_view_empty(self, value): + def send_if_view_empty(self, value: bool) -> None: self._send_if_view_empty = value @property - def suspended(self): + def suspended(self) -> bool: return self._suspended @suspended.setter @property_is_boolean - def suspended(self, value): + def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls, xml, ns): - parsed_response = ET.fromstring(xml) + def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] @@ -126,5 +132,5 @@ def _parse_element(cls, element, ns): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 2f47400f7..93edac63c 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,7 +1,7 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring -from .property_decorators import property_not_empty, property_is_boolean from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_empty, property_is_boolean class TableItem(object): @@ -128,7 +128,7 @@ def _set_permissions(self, permissions): @classmethod def from_response(cls, resp, ns): all_table_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_table_xml = parsed_response.findall(".//t:table", namespaces=ns) for table_xml in all_table_xml: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 01787de4e..e9760cbee 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,17 +1,19 @@ class TableauAuth(object): - def __init__(self, username, password, site=None, site_id="", user_id_to_impersonate=None): + def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): if site is not None: import warnings warnings.warn( - 'TableauAuth(...site=""...) is deprecated, ' 'please use TableauAuth(...site_id=""...) instead.', + "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", DeprecationWarning, ) site_id = site + if password is None: + raise TabError("Must provide a password when using traditional authentication") self.user_id_to_impersonate = user_id_to_impersonate self.password = password - self.site_id = site_id + self.site_id = site_id if site_id is not None else "" self.username = username @property diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index 055b04634..f7568ae45 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,13 +1,15 @@ +from typing import Set import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class TagItem(object): @classmethod - def from_response(cls, resp, ns): - return cls.from_xml_element(ET.fromstring(resp), ns) + def from_response(cls, resp: bytes, ns) -> Set[str]: + return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response, ns): + def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 65709d5c9..32299a853 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,6 +1,7 @@ -import xml.etree.ElementTree as ET -from .target import Target +from defusedxml.ElementTree import fromstring + from .schedule_item import ScheduleItem +from .target import Target from ..datetime_helpers import parse_datetime @@ -8,6 +9,7 @@ class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" + RunFlow = "runFlow" # This mapping is used to convert task type returned from server _TASK_TYPE_MAPPING = { @@ -43,7 +45,7 @@ def __repr__(self): @classmethod def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): - parsed_response = ET.fromstring(xml) + parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 65abf4cb6..b94f33725 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,17 +1,25 @@ import xml.etree.ElementTree as ET +from datetime import datetime +from typing import Dict, List, Optional, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, property_not_nullable, ) -from ..datetime_helpers import parse_datetime from .reference_item import ResourceReference +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from ..server.pager import Pager class UserItem(object): - tag_name = "user" + tag_name: str = "user" class Roles: Interactor = "Interactor" @@ -39,23 +47,27 @@ class Auth: SAML = "SAML" ServerDefault = "ServerDefault" - def __init__(self, name=None, site_role=None, auth_setting=None): - self._auth_setting = None - self._domain_name = None - self._external_auth_user_id = None - self._id = None - self._last_login = None + def __init__( + self, name: Optional[str] = None, site_role: Optional[str] = None, auth_setting: Optional[str] = None + ) -> None: + self._auth_setting: Optional[str] = None + self._domain_name: Optional[str] = None + self._external_auth_user_id: Optional[str] = None + self._id: Optional[str] = None + self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites = None + self._favorites: Optional[Dict[str, List]] = None self._groups = None - self.email = None - self.fullname = None - self.name = name - self.site_role = site_role - self.auth_setting = auth_setting + self.email: Optional[str] = None + self.fullname: Optional[str] = None + self.name: Optional[str] = name + self.site_role: Optional[str] = site_role + self.auth_setting: Optional[str] = auth_setting + + return None @property - def auth_setting(self): + def auth_setting(self) -> Optional[str]: return self._auth_setting @auth_setting.setter @@ -64,32 +76,32 @@ def auth_setting(self, value): self._auth_setting = value @property - def domain_name(self): + def domain_name(self) -> Optional[str]: return self._domain_name @property - def external_auth_user_id(self): + def external_auth_user_id(self) -> Optional[str]: return self._external_auth_user_id @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def last_login(self): + def last_login(self) -> Optional[datetime]: return self._last_login @property - def name(self): + def name(self) -> Optional[str]: return self._name @name.setter @property_not_empty - def name(self, value): + def name(self, value: str): self._name = value @property - def site_role(self): + def site_role(self) -> Optional[str]: return self._site_role @site_role.setter @@ -99,38 +111,38 @@ def site_role(self, value): self._site_role = value @property - def workbooks(self): + def workbooks(self) -> "Pager": if self._workbooks is None: error = "User item must be populated with workbooks first." raise UnpopulatedPropertyError(error) return self._workbooks() @property - def favorites(self): + def favorites(self) -> Dict[str, List]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) return self._favorites @property - def groups(self): + def groups(self) -> "Pager": if self._groups is None: error = "User item must be populated with groups first." raise UnpopulatedPropertyError(error) return self._groups() - def to_reference(self): + def to_reference(self) -> ResourceReference: return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_workbooks(self, workbooks): + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks - def _set_groups(self, groups): + def _set_groups(self, groups) -> None: self._groups = groups - def _parse_common_tags(self, user_xml, ns): + def _parse_common_tags(self, user_xml, ns) -> "UserItem": if not isinstance(user_xml, ET.Element): - user_xml = ET.fromstring(user_xml).find(".//t:user", namespaces=ns) + user_xml = fromstring(user_xml).find(".//t:user", namespaces=ns) if user_xml is not None: ( _, @@ -178,9 +190,9 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["UserItem"]: all_user_items = [] - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) for user_xml in all_user_xml: ( @@ -210,7 +222,7 @@ def from_response(cls, resp, ns): return all_user_items @staticmethod - def as_reference(id_): + def as_reference(id_) -> ResourceReference: return ResourceReference(id_, UserItem.tag_name) @staticmethod @@ -241,5 +253,5 @@ def _parse_element(user_xml, ns): domain_name, ) - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id, self.name, self.site_role) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index f18acfc33..146f21077 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,29 +1,37 @@ -import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime +import copy +from typing import Callable, Iterable, List, Optional, Set, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError from .tag_item import TagItem -import copy +from ..datetime_helpers import parse_datetime + +if TYPE_CHECKING: + from datetime import datetime + from .permissions_item import PermissionsRule class ViewItem(object): - def __init__(self): - self._content_url = None - self._created_at = None - self._id = None - self._image = None - self._initial_tags = set() - self._name = None - self._owner_id = None - self._preview_image = None - self._project_id = None - self._pdf = None - self._csv = None - self._total_views = None - self._sheet_type = None - self._updated_at = None - self._workbook_id = None - self._permissions = None - self.tags = set() + def __init__(self) -> None: + self._content_url: Optional[str] = None + self._created_at: Optional["datetime"] = None + self._id: Optional[str] = None + self._image: Optional[Callable[[], bytes]] = None + self._initial_tags: Set[str] = set() + self._name: Optional[str] = None + self._owner_id: Optional[str] = None + self._preview_image: Optional[Callable[[], bytes]] = None + self._project_id: Optional[str] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterable[bytes]]] = None + self._excel: Optional[Callable[[], Iterable[bytes]]] = None + self._total_views: Optional[int] = None + self._sheet_type: Optional[str] = None + self._updated_at: Optional["datetime"] = None + self._workbook_id: Optional[str] = None + self._permissions: Optional[Callable[[], List["PermissionsRule"]]] = None + self.tags: Set[str] = set() def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -37,60 +45,70 @@ def _set_pdf(self, pdf): def _set_csv(self, csv): self._csv = csv + def _set_excel(self, excel): + self._excel = excel + @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self): + def created_at(self) -> Optional["datetime"]: return self._created_at @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def image(self): + def image(self) -> bytes: if self._image is None: error = "View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() @property - def name(self): + def name(self) -> Optional[str]: return self._name @property - def owner_id(self): + def owner_id(self) -> Optional[str]: return self._owner_id @property - def preview_image(self): + def preview_image(self) -> bytes: if self._preview_image is None: error = "View item must be populated with its preview image first." raise UnpopulatedPropertyError(error) return self._preview_image() @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @property - def pdf(self): + def pdf(self) -> bytes: if self._pdf is None: error = "View item must be populated with its pdf first." raise UnpopulatedPropertyError(error) return self._pdf() @property - def csv(self): + def csv(self) -> Iterable[bytes]: if self._csv is None: error = "View item must be populated with its csv first." raise UnpopulatedPropertyError(error) return self._csv() @property - def sheet_type(self): + def excel(self) -> Iterable[bytes]: + if self._excel is None: + error = "View item must be populated with its excel first." + raise UnpopulatedPropertyError(error) + return self._excel() + + @property + def sheet_type(self) -> Optional[str]: return self._sheet_type @property @@ -101,29 +119,29 @@ def total_views(self): return self._total_views @property - def updated_at(self): + def updated_at(self) -> Optional["datetime"]: return self._updated_at @property - def workbook_id(self): + def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def permissions(self): + def permissions(self) -> List["PermissionsRule"]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions): + def _set_permissions(self, permissions: Callable[[], List["PermissionsRule"]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp, ns, workbook_id=""): - return cls.from_xml_element(ET.fromstring(resp), ns, workbook_id) + def from_response(cls, resp, ns, workbook_id="") -> List["ViewItem"]: + return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id=""): + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 5fc5c5749..e4d5e4aa0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,7 +1,8 @@ -import xml.etree.ElementTree as ET - import re +import xml.etree.ElementTree as ET +from typing import List, Optional, Tuple, Type +from defusedxml.ElementTree import fromstring NAMESPACE_RE = re.compile(r"^{.*}") @@ -14,11 +15,11 @@ def _parse_event(events): class WebhookItem(object): def __init__(self): - self._id = None - self.name = None - self.url = None - self._event = None - self.owner_id = None + self._id: Optional[str] = None + self.name: Optional[str] = None + self.url: Optional[str] = None + self._event: Optional[str] = None + self.owner_id: Optional[str] = None def _set_values(self, id, name, url, event, owner_id): if id is not None: @@ -33,23 +34,23 @@ def _set_values(self, id, name, url, event, owner_id): self.owner_id = owner_id @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def event(self): + def event(self) -> Optional[str]: if self._event: return self._event.replace("webhook-source-event-", "") return None @event.setter - def event(self, value): + def event(self, value: str) -> None: self._event = "webhook-source-event-{}".format(value) @classmethod - def from_response(cls, resp, ns): + def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: all_webhooks_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = 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) @@ -60,7 +61,7 @@ def from_response(cls, resp, ns): return all_webhooks_items @staticmethod - def _parse_element(webhook_xml, ns): + def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -80,5 +81,5 @@ def _parse_element(webhook_xml, ns): return id, name, url, event, owner_id - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 9c7e2022e..949970ced 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,5 +1,12 @@ +import copy +import uuid import xml.etree.ElementTree as ET +from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .property_decorators import ( property_not_nullable, property_is_boolean, @@ -7,32 +14,46 @@ ) from .tag_item import TagItem from .view_item import ViewItem -from .permissions_item import PermissionsRule from ..datetime_helpers import parse_datetime -import copy -import uuid + + +if TYPE_CHECKING: + from .connection_item import ConnectionItem + from .permissions_item import PermissionsRule + import datetime + from .revision_item import RevisionItem + +from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from .connection_item import ConnectionItem + from .permissions_item import PermissionsRule + import datetime class WorkbookItem(object): - def __init__(self, project_id, name=None, show_tabs=False): + def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None self._created_at = None - self._id = None - self._initial_tags = set() + self._id: Optional[str] = None + self._initial_tags: set = set() self._pdf = None + self._powerpoint = None self._preview_image = None self._project_name = None + self._revisions = None self._size = None self._updated_at = None self._views = None self.name = name self._description = None - self.owner_id = None + self.owner_id: Optional[str] = None self.project_id = project_id self.show_tabs = show_tabs - self.tags = set() + self.hidden_views: Optional[List[str]] = None + self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -41,74 +62,83 @@ def __init__(self, project_id, name=None, show_tabs=False): } self._permissions = None + return None + @property - def connections(self): + def connections(self) -> List["ConnectionItem"]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self): + def permissions(self) -> List["PermissionsRule"]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @property - def content_url(self): + def content_url(self) -> Optional[str]: return self._content_url @property - def webpage_url(self): + def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self): + def created_at(self) -> Optional["datetime.datetime"]: return self._created_at @property - def description(self): + def description(self) -> Optional[str]: return self._description @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def pdf(self): + def powerpoint(self) -> bytes: + if self._powerpoint is None: + error = "Workbook item must be populated with its powerpoint first." + raise UnpopulatedPropertyError(error) + return self._powerpoint() + + @property + def pdf(self) -> bytes: if self._pdf is None: error = "Workbook item must be populated with its pdf first." raise UnpopulatedPropertyError(error) return self._pdf() @property - def preview_image(self): + def preview_image(self) -> bytes: if self._preview_image is None: error = "Workbook item must be populated with its preview image first." raise UnpopulatedPropertyError(error) return self._preview_image() @property - def project_id(self): + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter @property_not_nullable - def project_id(self, value): + def project_id(self, value: str): self._project_id = value @property - def project_name(self): + def project_name(self) -> Optional[str]: return self._project_name @property - def show_tabs(self): + def show_tabs(self) -> bool: return self._show_tabs @show_tabs.setter @property_is_boolean - def show_tabs(self, value): + def show_tabs(self, value: bool): self._show_tabs = value @property @@ -116,11 +146,11 @@ def size(self): return self._size @property - def updated_at(self): + def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at @property - def views(self): + def views(self) -> List[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -145,24 +175,37 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def revisions(self) -> List["RevisionItem"]: + if self._revisions is None: + error = "Workbook item must be populated with revisions first." + raise UnpopulatedPropertyError(error) + return self._revisions() + def _set_connections(self, connections): self._connections = connections def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views): + def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: self._views = views - def _set_pdf(self, pdf): + def _set_pdf(self, pdf: Callable[[], bytes]) -> None: self._pdf = pdf - def _set_preview_image(self, preview_image): + def _set_powerpoint(self, pptx: Callable[[], bytes]) -> None: + self._powerpoint = pptx + + def _set_preview_image(self, preview_image: Callable[[], bytes]) -> None: self._preview_image = preview_image + def _set_revisions(self, revisions): + self._revisions = revisions + def _parse_common_tags(self, workbook_xml, ns): if not isinstance(workbook_xml, ET.Element): - workbook_xml = ET.fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) + workbook_xml = fromstring(workbook_xml).find(".//t:workbook", namespaces=ns) if workbook_xml is not None: ( _, @@ -253,9 +296,9 @@ def _set_values( self.data_acceleration_config = data_acceleration_config @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: all_workbook_items = list() - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) for workbook_xml in all_workbook_xml: ( @@ -394,5 +437,5 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 986a02fb3..d225ecff6 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -1,6 +1,7 @@ -from xml.etree import ElementTree as ET import re +from defusedxml.ElementTree import fromstring + OLD_NAMESPACE = "http://tableausoftware.com/api" NEW_NAMESPACE = "http://tableau.com/api" NAMESPACE_RE = re.compile(r"\{(.*?)\}") @@ -25,7 +26,7 @@ def detect(self, xml): if not xml.startswith(b" None: super(DataAlerts, self).__init__(parent_srv) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,7 +33,7 @@ def get(self, req_options=None): # Get 1 dataAlert @api(version="3.2") - def get_by_id(self, dataAlert_id): + def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) @@ -39,8 +43,13 @@ def get_by_id(self, dataAlert_id): return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.2") - def delete(self, dataAlert): - dataAlert_id = getattr(dataAlert, "id", dataAlert) + def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: + if isinstance(dataAlert, DataAlertItem): + dataAlert_id = dataAlert.id + elif isinstance(dataAlert, str): + dataAlert_id = dataAlert + else: + raise TypeError("dataAlert should be a DataAlertItem or a string of an id.") if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) @@ -50,9 +59,19 @@ def delete(self, dataAlert): logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) @api(version="3.2") - def delete_user_from_alert(self, dataAlert, user): - dataAlert_id = getattr(dataAlert, "id", dataAlert) - user_id = getattr(user, "id", user) + def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: + if isinstance(dataAlert, DataAlertItem): + dataAlert_id = dataAlert.id + elif isinstance(dataAlert, str): + dataAlert_id = dataAlert + else: + raise TypeError("dataAlert should be a DataAlertItem or a string of an id.") + if isinstance(user, UserItem): + user_id = user.id + elif isinstance(user, str): + user_id = user + else: + raise TypeError("user should be a UserItem or a string of an id.") if not dataAlert_id: error = "Dataalert ID undefined." raise ValueError(error) @@ -65,11 +84,16 @@ def delete_user_from_alert(self, dataAlert, user): logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) @api(version="3.2") - def add_user_to_alert(self, dataAlert_item, user): + def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: + if isinstance(user, UserItem): + user_id = user.id + elif isinstance(user, str): + user_id = user + else: + raise TypeError("user should be a UserItem or a string of an id.") if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - user_id = getattr(user, "id", user) if not user_id: error = "User ID undefined." raise ValueError(error) @@ -77,11 +101,11 @@ def add_user_to_alert(self, dataAlert_item, user): update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) - user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] - return user + added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return added_user @api(version="3.2") - def update(self, dataAlert_item): + def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: if not dataAlert_item.id: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 50826ee0b..255b7b7a3 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,13 +1,12 @@ +import logging + +from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint - from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission -import logging - logger = logging.getLogger("tableau.endpoint.databases") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 18a2f318c..cb5600938 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,9 +1,27 @@ +import cgi +import copy +import io +import json +import logging +import os +from contextlib import closing +from pathlib import Path +from typing import ( + List, + Mapping, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) + +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem +from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem, RequestOptions from ..query import QuerySet from ...filesys_helpers import ( to_filename, @@ -11,14 +29,24 @@ get_file_type, get_file_object_size, ) +from ...models import ConnectionCredentials, RevisionItem from ...models.job_item import JobItem +from ...models import ConnectionCredentials + +io_types = (io.BytesIO, io.BufferedReader) + +from pathlib import Path +from typing import ( + List, + Mapping, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) -import os -import logging -import copy -import cgi -from contextlib import closing -import json +io_types = (io.BytesIO, io.BufferedReader) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -27,21 +55,32 @@ logger = logging.getLogger("tableau.endpoint.datasources") +if TYPE_CHECKING: + from ..server import Server + from ...models import PermissionsRule + from .schedules_endpoint import AddResponse + +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] + class Datasources(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") + return None + @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: RequestOptions = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -51,7 +90,7 @@ def get(self, req_options=None): # Get 1 datasource by id @api(version="2.0") - def get_by_id(self, datasource_id): + def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -62,7 +101,7 @@ def get_by_id(self, datasource_id): # Populate datasource item's connections @api(version="2.0") - def populate_connections(self, datasource_item): + def populate_connections(self, datasource_item: DatasourceItem) -> None: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -81,7 +120,7 @@ def _get_datasource_connections(self, datasource_item, req_options=None): # Delete 1 datasource by id @api(version="2.0") - def delete(self, datasource_id): + def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -93,7 +132,13 @@ def delete(self, datasource_id): @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") - def download(self, datasource_id, filepath=None, include_extract=True, no_extract=None): + def download( + self, + datasource_id: str, + filepath: FilePath = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -126,7 +171,7 @@ def download(self, datasource_id, filepath=None, include_extract=True, no_extrac # Update datasource @api(version="2.0") - def update(self, datasource_item): + def update(self, datasource_item: DatasourceItem) -> DatasourceItem: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -143,7 +188,7 @@ def update(self, datasource_item): # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item, connection_item): + def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) @@ -156,7 +201,7 @@ def update_connection(self, datasource_item, connection_item): return connection @api(version="2.8") - def refresh(self, datasource_item): + def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -165,7 +210,7 @@ def refresh(self, datasource_item): return new_job @api(version="3.5") - def create_extract(self, datasource_item, encrypt=False): + def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) empty_req = RequestFactory.Empty.empty_req() @@ -174,7 +219,7 @@ def create_extract(self, datasource_item, encrypt=False): return new_job @api(version="3.5") - def delete_extract(self, datasource_item): + def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -186,16 +231,15 @@ def delete_extract(self, datasource_item): @parameter_added_in(as_job="3.0") def publish( self, - datasource_item, - file, - mode, - connection_credentials=None, - connections=None, - as_job=False, - ): - - try: - + datasource_item: DatasourceItem, + file: PathOrFile, + mode: str, + connection_credentials: ConnectionCredentials = None, + connections: Sequence[ConnectionItem] = None, + as_job: bool = False, + ) -> Union[DatasourceItem, JobItem]: + + if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." raise IOError(error) @@ -211,7 +255,7 @@ def publish( error = "Only {} files can be published as datasources.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - except TypeError: + elif isinstance(file, io_types): if not datasource_item.name: error = "Datasource item must have a name when passing a file object" @@ -229,6 +273,9 @@ def publish( filename = "{}.{}".format(datasource_item.name, file_extension) file_size = get_file_object_size(file) + else: + raise TypeError("file should be a filepath or file object.") + if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -252,11 +299,13 @@ def publish( else: logger.info("Publishing {0} to server".format(filename)) - try: + if isinstance(file, (Path, str)): with open(file, "rb") as f: file_contents = f.read() - except TypeError: + elif isinstance(file, io_types): file_contents = file.read() + else: + raise TypeError("file should be a filepath or file object.") xml_request, content_type = RequestFactory.Datasource.publish_req( datasource_item, @@ -284,7 +333,14 @@ def publish( return new_datasource @api(version="3.13") - def update_hyper_data(self, datasource_or_connection_item, *, request_id, actions, payload = None): + def update_hyper_data( + self, + datasource_or_connection_item: Union[DatasourceItem, ConnectionItem, str], + *, + request_id: str, + actions: Sequence[Mapping], + payload: Optional[FilePath] = None + ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = "{0}/{1}/data".format(self.baseurl, datasource_id) @@ -312,7 +368,7 @@ def update_hyper_data(self, datasource_or_connection_item, *, request_id, action return new_job @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") @@ -327,11 +383,11 @@ def update_permission(self, item, permission_item): self._permissions.update(item, permission_item) @api(version="2.0") - def update_permissions(self, item, permission_item): + def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None: self._permissions.delete(item, capability_item) @api(version="3.5") @@ -349,3 +405,83 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + # Populate datasource item's revisions + @api(version="2.3") + def populate_revisions(self, datasource_item: DatasourceItem) -> None: + if not datasource_item.id: + error = "Datasource item missing ID. Datasource must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_datasource_revisions(datasource_item) + + datasource_item._set_revisions(revisions_fetcher) + logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + + def _get_datasource_revisions( + self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) + return revisions + + # Download 1 datasource revision by revision number + @api(version="2.3") + def download_revision( + self, + datasource_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: + if not datasource_id: + error = "Datasource ID undefined." + raise ValueError(error) + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + if no_extract is False or no_extract is True: + import warnings + + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) + include_extract = not no_extract + + if not include_extract: + url += "?includeExtract=False" + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, download_path, datasource_id) + ) + return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, datasource_id: str, revision_number: str) -> None: + if datasource_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info( + "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) + ) + + # a convenience method + @api(version="2.8") + def schedule_extract_refresh( + self, schedule_id: str, item: DatasourceItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 1cfa41733..6e54d02c7 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -1,13 +1,27 @@ import logging +from .endpoint import Endpoint +from .exceptions import MissingRequiredFieldError from .. import RequestFactory from ...models import PermissionsRule -from .endpoint import Endpoint -from .exceptions import MissingRequiredFieldError +logger = logging.getLogger(__name__) +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ...models import ( + DatasourceItem, + FlowItem, + ProjectItem, + ViewItem, + WorkbookItem, + ) + + from ..server import Server + from ..request_options import RequestOptions + + TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] class _DefaultPermissionsEndpoint(Endpoint): @@ -19,7 +33,7 @@ class _DefaultPermissionsEndpoint(Endpoint): has these supported endpoints """ - def __init__(self, parent_srv, owner_baseurl): + def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda @@ -27,7 +41,9 @@ def __init__(self, parent_srv, owner_baseurl): # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl - def update_default_permissions(self, resource, permissions, content_type): + def update_default_permissions( + self, resource: "TableauItem", permissions: Sequence[PermissionsRule], content_type: str + ) -> List[PermissionsRule]: url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s") update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) @@ -36,7 +52,7 @@ def update_default_permissions(self, resource, permissions, content_type): return permissions - def delete_default_permission(self, resource, rule, content_type): + def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRule, content_type: str) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -60,18 +76,20 @@ def delete_default_permission(self, resource, rule, content_type): "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate_default_permissions(self, item, content_type): + def populate_default_permissions(self, item: "ProjectItem", content_type: str) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher(): + def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type)) - def _get_default_permissions(self, item, content_type, req_options=None): + def _get_default_permissions( + self, item: "TableauItem", content_type: str, req_options: Optional["RequestOptions"] = None + ) -> List[PermissionsRule]: url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index e19ca7d90..ff1637721 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,10 +1,8 @@ import logging -from .. import RequestFactory, DQWItem - from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError - +from .. import RequestFactory, DQWItem logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3372afdf1..8fdb74751 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,23 +1,23 @@ +import logging +from distutils.version import LooseVersion as Version +from functools import wraps +from xml.etree.ElementTree import ParseError + from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, EndpointUnavailableError, ) -from functools import wraps -from xml.etree.ElementTree import ParseError from ..query import QuerySet -import logging - -try: - from distutils2.version import NormalizedVersion as Version -except ImportError: - from packaging.version import Version logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] +XML_CONTENT_TYPE = "text/xml" +JSON_CONTENT_TYPE = "application/json" + class Endpoint(object): def __init__(self, parent_srv): @@ -62,9 +62,9 @@ def _make_request( if content is not None: parameters["data"] = content - logger.debug(u"request {}, url: {}".format(method.__name__, url)) + logger.debug("request {}, url: {}".format(method.__name__, url)) if content: - logger.debug(u"request content: {}".format(content[:1000])) + logger.debug("request content: {}".format(content[:1000])) server_response = method(url, **parameters) self.parent_srv._namespace.detect(server_response.content) @@ -74,9 +74,7 @@ def _make_request( # so that we do not attempt to log bytes and other binary data. if len(server_response.content) > 0 and server_response.encoding: logger.debug( - u"Server response from {0}:\n\t{1}".format( - url, server_response.content.decode(server_response.encoding) - ) + "Server response from {0}:\n\t{1}".format(url, server_response.content.decode(server_response.encoding)) ) return server_response @@ -230,7 +228,9 @@ def all(self, *args, **kwargs): return queryset @api(version="2.0") - def filter(self, *args, **kwargs): + def filter(self, *_, **kwargs): + if _: + raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) return queryset diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 48dcaf4c8..34de00dd0 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring class ServerResponseError(Exception): @@ -14,7 +14,7 @@ def __str__(self): @classmethod def from_response(cls, resp, ns): # Check elements exist before .text - parsed_response = ET.fromstring(resp) + parsed_response = fromstring(resp) error_response = cls( parsed_response.find("t:error", namespaces=ns).get("code", ""), parsed_response.find(".//t:summary", namespaces=ns).text, @@ -70,13 +70,15 @@ class JobFailedException(Exception): def __init__(self, job): self.notes = job.notes self.job = job - + def __str__(self): return f"Job {self.job.id} failed with notes {self.notes}" class JobCancelledException(JobFailedException): pass + + class FlowRunFailedException(Exception): def __init__(self, flow_run): self.background_job_id = flow_run.background_job_id @@ -87,4 +89,4 @@ def __str__(self): class FlowRunCancelledException(FlowRunFailedException): - pass + pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 459d852e6..19199c5a0 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,23 +1,26 @@ +import logging + from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError from .. import RequestFactory from ...models import FavoriteItem -from ..pager import Pager -import xml.etree.ElementTree as ET -import logging -import copy logger = logging.getLogger("tableau.endpoint.favorites") +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem + from ..request_options import RequestOptions + class Favorites(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all favorites @api(version="2.5") - def get(self, user_item, req_options=None): + def get(self, user_item: "UserItem", req_options: Optional["RequestOptions"] = None) -> None: logger.info("Querying all favorites for user {0}".format(user_item.name)) url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) @@ -25,53 +28,66 @@ def get(self, user_item, req_options=None): user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="2.0") - def add_favorite_workbook(self, user_item, workbook_item): + def add_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") - def add_favorite_view(self, user_item, view_item): + def add_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") - def add_favorite_datasource(self, user_item, datasource_item): + def add_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") - def add_favorite_project(self, user_item, project_item): + def add_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + @api(version="3.3") + def add_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + @api(version="2.0") - def delete_favorite_workbook(self, user_item, workbook_item): + def delete_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") - def delete_favorite_view(self, user_item, view_item): + def delete_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") - def delete_favorite_datasource(self, user_item, datasource_item): + def delete_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") - def delete_favorite_project(self, user_item, project_item): + def delete_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) + + @api(version="3.3") + def delete_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index b70cffbaa..3df8ee4d5 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,9 +1,8 @@ -from .exceptions import MissingRequiredFieldError +import logging + from .endpoint import Endpoint, api from .. import RequestFactory from ...models.fileupload_item import FileuploadItem -import os.path -import logging # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2ae1973d4..62f910dea 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,24 +1,30 @@ -from .endpoint import Endpoint, QuerysetEndpoint, api +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +from .endpoint import QuerysetEndpoint, api from .exceptions import FlowRunFailedException, FlowRunCancelledException from .. import FlowRunItem, PaginationItem from ...exponential_backoff import ExponentialBackoffTimer -import logging - logger = logging.getLogger("tableau.endpoint.flowruns") +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + class FlowRuns(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(FlowRuns, self).__init__(parent_srv) + return None @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.10") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -28,7 +34,7 @@ def get(self, req_options=None): # Get 1 flow by id @api(version="3.10") - def get_by_id(self, flow_run_id): + def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) @@ -37,21 +43,19 @@ def get_by_id(self, flow_run_id): server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id): + def cancel(self, flow_run_id: str) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - id_ = getattr(flow_run_id, 'id', flow_run_id) + id_ = getattr(flow_run_id, "id", flow_run_id) url = "{0}/{1}".format(self.baseurl, id_) self.put_request(url) logger.info("Deleted single flow (ID: {0})".format(id_)) - @api(version="3.10") - def wait_for_job(self, flow_run_id, *, timeout=None): + def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: if isinstance(flow_run_id, FlowRunItem): flow_run_id = flow_run_id.id assert isinstance(flow_run_id, str) @@ -73,4 +77,4 @@ def wait_for_job(self, flow_run_id, *, timeout=None): elif flow_run.status == "Cancelled": raise FlowRunCancelledException(flow_run) else: - raise AssertionError("Unexpected status in flow_run", flow_run) \ No newline at end of file + raise AssertionError("Unexpected status in flow_run", flow_run) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index eb2de4ac9..2c54d17c4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,18 +1,19 @@ -from .endpoint import Endpoint, api +import cgi +import copy +import logging +import os +from contextlib import closing +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union + +from .dqw_endpoint import _DataQualityWarningEndpoint +from .endpoint import Endpoint, QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem from ...filesys_helpers import to_filename, make_download_path from ...models.job_item import JobItem -import os -import logging -import copy -import cgi -from contextlib import closing - # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -20,8 +21,17 @@ logger = logging.getLogger("tableau.endpoint.flows") +if TYPE_CHECKING: + from .. import DQWItem + from ..request_options import RequestOptions + from ...models.permissions_item import PermissionsRule + from .schedules_endpoint import AddResponse + -class Flows(Endpoint): +FilePath = Union[str, os.PathLike] + + +class Flows(QuerysetEndpoint): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -29,12 +39,12 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,7 +54,7 @@ def get(self, req_options=None): # Get 1 flow by id @api(version="3.3") - def get_by_id(self, flow_id): + def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -55,7 +65,7 @@ def get_by_id(self, flow_id): # Populate flow item's connections @api(version="3.3") - def populate_connections(self, flow_item): + def populate_connections(self, flow_item: FlowItem) -> None: if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -66,7 +76,7 @@ def connections_fetcher(): flow_item._set_connections(connections_fetcher) logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) - def _get_flow_connections(self, flow_item, req_options=None): + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -74,7 +84,7 @@ def _get_flow_connections(self, flow_item, req_options=None): # Delete 1 flow by id @api(version="3.3") - def delete(self, flow_id): + def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -84,7 +94,7 @@ def delete(self, flow_id): # Download 1 flow by id @api(version="3.3") - def download(self, flow_id, filepath=None): + def download(self, flow_id: str, filepath: FilePath = None) -> str: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -105,7 +115,7 @@ def download(self, flow_id, filepath=None): # Update flow @api(version="3.3") - def update(self, flow_item): + def update(self, flow_item: FlowItem) -> FlowItem: if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -122,7 +132,7 @@ def update(self, flow_item): # Update flow connections @api(version="3.3") - def update_connection(self, flow_item, connection_item): + def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) @@ -133,7 +143,7 @@ def update_connection(self, flow_item, connection_item): return connection @api(version="3.3") - def refresh(self, flow_item): + def refresh(self, flow_item: FlowItem) -> JobItem: url = "{0}/{1}/run".format(self.baseurl, flow_item.id) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -142,7 +152,9 @@ def refresh(self, flow_item): # Publish flow @api(version="3.3") - def publish(self, flow_item, file_path, mode, connections=None): + def publish( + self, flow_item: FlowItem, file_path: FilePath, mode: str, connections: Optional[List[ConnectionItem]] = None + ) -> FlowItem: if not os.path.isfile(file_path): error = "File path does not lead to an existing file." raise IOError(error) @@ -189,13 +201,8 @@ def publish(self, flow_item, file_path, mode, connections=None): logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow - server_response = self.post_request(url, xml_request, content_type) - new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) - return new_flow - @api(version="3.3") - def populate_permissions(self, item): + def populate_permissions(self, item: FlowItem) -> None: self._permissions.populate(item) @api(version="3.3") @@ -209,25 +216,32 @@ def update_permission(self, item, permission_item): self._permissions.update(item, permission_item) @api(version="3.3") - def update_permissions(self, item, permission_item): + def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="3.3") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None: self._permissions.delete(item, capability_item) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: FlowItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: FlowItem) -> None: self._data_quality_warnings.clear(item) + + # a convenience method + @api(version="3.3") + def schedule_flow_run( + self, schedule_id: str, item: FlowItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index b771e56d8..289ccdb11 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,21 +1,26 @@ -from .endpoint import Endpoint, api +import logging + +from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -import logging - logger = logging.getLogger("tableau.endpoint.groups") +from typing import List, Optional, TYPE_CHECKING, Tuple, Union + +if TYPE_CHECKING: + from ..request_options import RequestOptions + -class Groups(Endpoint): +class Groups(QuerysetEndpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all groups @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +30,7 @@ def get(self, req_options=None): # Gets all users in a given group @api(version="2.0") - def populate_users(self, group_item, req_options=None): + def populate_users(self, group_item, req_options: Optional["RequestOptions"] = None) -> None: if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -40,7 +45,9 @@ def user_pager(): group_item._set_users(user_pager) - def _get_users_for_group(self, group_item, req_options=None): + def _get_users_for_group( + self, group_item, req_options: Optional["RequestOptions"] = None + ) -> Tuple[List[UserItem], PaginationItem]: url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) @@ -50,7 +57,7 @@ def _get_users_for_group(self, group_item, req_options=None): # Deletes 1 group by id @api(version="2.0") - def delete(self, group_id): + def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -59,7 +66,9 @@ def delete(self, group_id): logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") - def update(self, group_item, default_site_role=None, as_job=False): + def update( + self, group_item: GroupItem, default_site_role: Optional[str] = None, as_job: bool = False + ) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: import warnings @@ -90,7 +99,7 @@ def update(self, group_item, default_site_role=None, as_job=False): # Create a 'local' Tableau group @api(version="2.0") - def create(self, group_item): + def create(self, group_item: GroupItem) -> GroupItem: url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) @@ -98,7 +107,7 @@ def create(self, group_item): # Create a group based on Active Directory @api(version="2.0") - def create_AD_group(self, group_item, asJob=False): + def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -110,7 +119,7 @@ def create_AD_group(self, group_item, asJob=False): # Removes 1 user from 1 group @api(version="2.0") - def remove_user(self, group_item, user_id): + def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -123,7 +132,7 @@ def remove_user(self, group_item, user_id): # Adds 1 user to 1 group @api(version="2.0") - def add_user(self, group_item, user_id): + def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 4cdbcc5be..99870ac34 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,20 +1,25 @@ -from .endpoint import Endpoint, api +import logging + +from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException from .. import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase from ...exponential_backoff import ExponentialBackoffTimer -import logging - logger = logging.getLogger("tableau.endpoint.jobs") -class Jobs(Endpoint): +from typing import List, Optional, Tuple, Union + + +class Jobs(QuerysetEndpoint): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.6") - def get(self, job_id=None, req_options=None): + def get( + self, job_id: Optional[str] = None, req_options: Optional[RequestOptionsBase] = None + ) -> Tuple[List[BackgroundJobItem], PaginationItem]: # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -31,21 +36,22 @@ def get(self, job_id=None, req_options=None): return jobs, pagination_item @api(version="3.1") - def cancel(self, job_id): - id_ = getattr(job_id, "id", job_id) - url = "{0}/{1}".format(self.baseurl, id_) + def cancel(self, job_id: Union[str, JobItem]): + if isinstance(job_id, JobItem): + job_id = job_id.id + assert isinstance(job_id, str) + url = "{0}/{1}".format(self.baseurl, job_id) return self.put_request(url) @api(version="2.6") - def get_by_id(self, job_id): + def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job - @api(version="2.6") - def wait_for_job(self, job_id, *, timeout=None): + def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index adc7b2666..06339fa79 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -1,8 +1,8 @@ -from .endpoint import Endpoint, api -from .exceptions import GraphQLError, InvalidGraphQLQuery -import logging import json +import logging +from .endpoint import Endpoint, api +from .exceptions import GraphQLError, InvalidGraphQLQuery logger = logging.getLogger("tableau.endpoint.metadata") diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py new file mode 100644 index 000000000..fba2632a4 --- /dev/null +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -0,0 +1,78 @@ +from .endpoint import QuerysetEndpoint, api +from .exceptions import MissingRequiredFieldError +from .permissions_endpoint import _PermissionsEndpoint +from .dqw_endpoint import _DataQualityWarningEndpoint +from .resource_tagger import _ResourceTagger +from .. import RequestFactory, PaginationItem +from ...models.metric_item import MetricItem + +import logging +import copy + +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + from ...server import Server + + +logger = logging.getLogger("tableau.endpoint.metrics") + + +class Metrics(QuerysetEndpoint): + def __init__(self, parent_srv: "Server") -> None: + super(Metrics, self).__init__(parent_srv) + self._resource_tagger = _ResourceTagger(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") + + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + # Get all metrics + @api(version="3.9") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + logger.info("Querying all metrics on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_metric_items = MetricItem.from_response(server_response.content, self.parent_srv.namespace) + return all_metric_items, pagination_item + + # Get 1 metric by id + @api(version="3.9") + def get_by_id(self, metric_id: str) -> MetricItem: + if not metric_id: + error = "Metric ID undefined." + raise ValueError(error) + logger.info("Querying single metric (ID: {0})".format(metric_id)) + url = "{0}/{1}".format(self.baseurl, metric_id) + server_response = self.get_request(url) + return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + # Delete 1 metric by id + @api(version="3.9") + def delete(self, metric_id: str) -> None: + if not metric_id: + error = "Metric ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, metric_id) + self.delete_request(url) + logger.info("Deleted single metric (ID: {0})".format(metric_id)) + + # Update metric + @api(version="3.9") + def update(self, metric_item: MetricItem) -> MetricItem: + if not metric_item.id: + error = "Metric item missing ID. Metric must be retrieved from server first." + raise MissingRequiredFieldError(error) + + self._resource_tagger.update_tags(self.baseurl, metric_item) + + # Update the metric itself + url = "{0}/{1}".format(self.baseurl, metric_item.id) + update_req = RequestFactory.Metric.update_req(metric_item) + server_response = self.put_request(url, update_req) + logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 5013a0bef..10a1d9fac 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -5,20 +5,28 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError +from typing import Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ...models import DatasourceItem, ProjectItem, WorkbookItem, ViewItem + from ..server import Server + from ..request_options import RequestOptions + +TableauItem = Union["DatasourceItem", "ProjectItem", "WorkbookItem", "ViewItem"] + class _PermissionsEndpoint(Endpoint): """Adds permission model to another endpoint - Tableau permissions model is identical between objects but they are nested under + Tableau permissions model is identical between objects, but they are nested under the parent object endpoint (i.e. permissions for workbooks are under - /workbooks/:id/permission). This class is meant to be instantated inside a + /workbooks/:id/permission). This class is meant to be instantiated inside a parent endpoint which has these supported endpoints """ - def __init__(self, parent_srv, owner_baseurl): + def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_PermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda @@ -26,7 +34,7 @@ def __init__(self, parent_srv, owner_baseurl): # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl - def update(self, resource, permissions): + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) @@ -35,7 +43,7 @@ def update(self, resource, permissions): return permissions - def delete(self, resource, rules): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -62,7 +70,7 @@ def delete(self, resource, rules): "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate(self, item): + def populate(self, item: TableauItem): if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -73,7 +81,7 @@ def permission_fetcher(): item._set_permissions(permission_fetcher) logger.info("Populated permissions for item (ID: {0})".format(item.id)) - def _get_permissions(self, item, req_options=None): + def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = None): url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 72286e570..b21ba3682 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,28 +1,33 @@ -from .endpoint import api, Endpoint +import logging + +from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .default_permissions_endpoint import _DefaultPermissionsEndpoint +from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Permission -from .. import RequestFactory, ProjectItem, PaginationItem, Permission +logger = logging.getLogger("tableau.endpoint.projects") -import logging +from typing import List, Optional, Tuple, TYPE_CHECKING -logger = logging.getLogger("tableau.endpoint.projects") +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions -class Projects(Endpoint): - def __init__(self, parent_srv): +class Projects(QuerysetEndpoint): + def __init__(self, parent_srv: "Server") -> None: super(Projects, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -31,7 +36,7 @@ def get(self, req_options=None): return all_project_items, pagination_item @api(version="2.0") - def delete(self, project_id): + def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) @@ -40,29 +45,31 @@ def delete(self, project_id): logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") - def update(self, project_item): + def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: if not project_item.id: error = "Project item missing ID." raise MissingRequiredFieldError(error) + params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) - server_response = self.put_request(url, update_req) + server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @api(version="2.0") - def create(self, project_item): + def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl create_req = RequestFactory.Project.create_req(project_item) - server_response = self.post_request(url, create_req) + server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index a38c66ebe..d5bc4dccb 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,10 +1,11 @@ +import copy +import logging +import urllib.parse + from .endpoint import Endpoint from .exceptions import EndpointUnavailableError, ServerResponseError from .. import RequestFactory from ...models.tag_item import TagItem -import logging -import copy -import urllib.parse logger = logging.getLogger("tableau.endpoint.resource_tagger") diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index d582dca26..21c828989 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,27 +1,33 @@ -from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem -import logging import copy +import logging +import warnings from collections import namedtuple +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union + +from .endpoint import Endpoint, api, parameter_added_in +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem logger = logging.getLogger("tableau.endpoint.schedules") -# Oh to have a first class Result concept in Python... AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) +if TYPE_CHECKING: + from ..request_options import RequestOptions + from ...models import DatasourceItem, WorkbookItem, FlowItem + class Schedules(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/schedules".format(self.parent_srv.baseurl) @property - def siteurl(self): + def siteurl(self) -> str: return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,8 +35,18 @@ def get(self, req_options=None): all_schedule_items = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace) return all_schedule_items, pagination_item + @api(version="3.8") + def get_by_id(self, schedule_id): + if not schedule_id: + error = "No Schedule ID provided" + raise ValueError(error) + logger.info("Querying a single schedule by id ({})".format(schedule_id)) + url = "{0}/{1}".format(self.baseurl, schedule_id) + server_response = self.get_request(url) + return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="2.3") - def delete(self, schedule_id): + def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -39,7 +55,7 @@ def delete(self, schedule_id): logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) @api(version="2.3") - def update(self, schedule_item): + def update(self, schedule_item: ScheduleItem) -> ScheduleItem: if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -52,7 +68,7 @@ def update(self, schedule_item): return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @api(version="2.3") - def create(self, schedule_item): + def create(self, schedule_item: ScheduleItem) -> ScheduleItem: if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -65,42 +81,71 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") + @parameter_added_in(flow="3.3") def add_to_schedule( self, - schedule_id, - workbook=None, - datasource=None, - task_type=TaskItem.Type.ExtractRefresh, - ): - def add_to(resource, type_, req_factory): - id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) - add_req = req_factory(id_, task_type=task_type) - response = self.put_request(url, add_req) - - error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response( - response, self.parent_srv.namespace - ) - if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) - - if error is not None or warnings is not None: - return AddResponse( - result=False, - error=error, - warnings=warnings, - task_created=task_created, - ) - else: - return OK - - items = [] + schedule_id: str, + workbook: "WorkbookItem" = None, + datasource: "DatasourceItem" = None, + flow: "FlowItem" = None, + task_type: str = None, + ) -> List[AddResponse]: + + # There doesn't seem to be a good reason to allow one item of each type? + if workbook and datasource: + warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) + items: List[ + Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + ] = [] if workbook is not None: - items.append((workbook, "workbook", RequestFactory.Schedule.add_workbook_req)) + if not task_type: + task_type = TaskItem.Type.ExtractRefresh + items.append((schedule_id, workbook, "workbook", RequestFactory.Schedule.add_workbook_req, task_type)) if datasource is not None: - items.append((datasource, "datasource", RequestFactory.Schedule.add_datasource_req)) - - results = (add_to(*x) for x in items) + if not task_type: + task_type = TaskItem.Type.ExtractRefresh + items.append((schedule_id, datasource, "datasource", RequestFactory.Schedule.add_datasource_req, task_type)) + if flow is not None and not (workbook or datasource): # Cannot pass a flow with any other type + if not task_type: + task_type = TaskItem.Type.RunFlow + items.append( + (schedule_id, flow, "flow", RequestFactory.Schedule.add_flow_req, task_type) + ) # type:ignore[arg-type] + + results = (self._add_to(*x) for x in items) # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) + return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + + def _add_to( + self, + schedule_id, + resource: Union["DatasourceItem", "WorkbookItem", "FlowItem"], + type_: str, + req_factory: Callable[ + [ + str, + str, + ], + bytes, + ], + item_task_type, + ) -> AddResponse: + id_ = resource.id + url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] + response = self.put_request(url, add_req) + + error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) + if task_created: + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + + if error is not None or warnings is not None: + return AddResponse( + result=False, + error=error, + warnings=warnings, + task_created=task_created, + ) + else: + return OK diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ca3715fca..5c9461d1c 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,3 +1,5 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import ( ServerResponseError, @@ -5,7 +7,6 @@ EndpointUnavailableError, ) from ...models import ServerInfoItem -import logging logger = logging.getLogger("tableau.endpoint.server_info") diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 9446a01a8..bdf281fb9 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -1,21 +1,26 @@ +import copy +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SiteItem, PaginationItem -import copy -import logging - logger = logging.getLogger("tableau.endpoint.sites") +from typing import TYPE_CHECKING, List, Optional, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + class Sites(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites".format(self.parent_srv.baseurl) # Gets all sites @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +30,7 @@ def get(self, req_options=None): # Gets 1 site by id @api(version="2.0") - def get_by_id(self, site_id): + def get_by_id(self, site_id: str) -> SiteItem: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -36,7 +41,7 @@ def get_by_id(self, site_id): # Gets 1 site by name @api(version="2.0") - def get_by_name(self, site_name): + def get_by_name(self, site_name: str) -> SiteItem: if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -47,7 +52,7 @@ def get_by_name(self, site_name): # Gets 1 site by content url @api(version="2.0") - def get_by_content_url(self, content_url): + def get_by_content_url(self, content_url: str) -> SiteItem: if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -58,7 +63,7 @@ def get_by_content_url(self, content_url): # Update site @api(version="2.0") - def update(self, site_item): + def update(self, site_item: SiteItem) -> SiteItem: if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -76,7 +81,7 @@ def update(self, site_item): # Delete 1 site object @api(version="2.0") - def delete(self, site_id): + def delete(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -91,7 +96,7 @@ def delete(self, site_id): # Create new site @api(version="2.0") - def create(self, site_item): + def create(self, site_item: SiteItem) -> SiteItem: if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -105,7 +110,7 @@ def create(self, site_item): return new_site @api(version="3.5") - def encrypt_extracts(self, site_id): + def encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,7 +119,7 @@ def encrypt_extracts(self, site_id): self.post_request(url, empty_req) @api(version="3.5") - def decrypt_extracts(self, site_id): + def decrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -123,7 +128,7 @@ def decrypt_extracts(self, site_id): self.post_request(url, empty_req) @api(version="3.5") - def re_encrypt_extracts(self, site_id): + def re_encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 1a66e8ac5..6b929524e 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -1,19 +1,24 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import RequestFactory, SubscriptionItem, PaginationItem -import logging - logger = logging.getLogger("tableau.endpoint.subscriptions") +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..request_options import RequestOptions + class Subscriptions(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -23,7 +28,7 @@ def get(self, req_options=None): return all_subscriptions, pagination_item @api(version="2.3") - def get_by_id(self, subscription_id): + def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) @@ -33,7 +38,7 @@ def get_by_id(self, subscription_id): return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.3") - def create(self, subscription_item): + def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) @@ -44,7 +49,7 @@ def create(self, subscription_item): return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.3") - def delete(self, subscription_id): + def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) @@ -53,7 +58,7 @@ def delete(self, subscription_id): logger.info("Deleted subscription (ID: {0})".format(subscription_id)) @api(version="2.3") - def update(self, subscription_item): + def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index ac53484db..e41ab07ca 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,12 +1,11 @@ +import logging + +from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint -from ..pager import Pager - from .. import RequestFactory, TableItem, ColumnItem, PaginationItem - -import logging +from ..pager import Pager logger = logging.getLogger("tableau.endpoint.tables") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index aaa5069c3..339952704 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,9 +1,9 @@ +import logging + from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from .. import TaskItem, PaginationItem, RequestFactory -import logging - logger = logging.getLogger("tableau.endpoint.tasks") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 6adbf92fb..a1984d5d6 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,3 +1,7 @@ +import copy +import logging +from typing import List, Tuple + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .. import ( @@ -10,20 +14,17 @@ ) from ..pager import Pager -import copy -import logging - logger = logging.getLogger("tableau.endpoint.users") class Users(QuerysetEndpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all users @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: RequestOptions = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -38,7 +39,7 @@ def get(self, req_options=None): # Gets 1 user by id @api(version="2.0") - def get_by_id(self, user_id): + def get_by_id(self, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) @@ -49,7 +50,7 @@ def get_by_id(self, user_id): # Update user @api(version="2.0") - def update(self, user_item, password=None): + def update(self, user_item: UserItem, password: str = None) -> UserItem: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -63,7 +64,7 @@ def update(self, user_item, password=None): # Delete 1 user by id @api(version="2.0") - def remove(self, user_id): + def remove(self, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,7 +74,7 @@ def remove(self, user_id): # Add new user to site @api(version="2.0") - def add(self, user_item): + def add(self, user_item: UserItem) -> UserItem: url = self.baseurl add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) @@ -83,7 +84,7 @@ def add(self, user_item): # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item, req_options=None): + def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -93,7 +94,9 @@ def wb_pager(): user_item._set_workbooks(wb_pager) - def _get_wbs_for_user(self, user_item, req_options=None): + def _get_wbs_for_user( + self, user_item: UserItem, req_options: RequestOptions = None + ) -> Tuple[List[WorkbookItem], PaginationItem]: url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) @@ -101,12 +104,12 @@ def _get_wbs_for_user(self, user_item, req_options=None): pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item - def populate_favorites(self, user_item): + def populate_favorites(self, user_item: UserItem) -> None: self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") - def populate_groups(self, user_item, req_options=None): + def populate_groups(self, user_item: UserItem, req_options: RequestOptions = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -119,7 +122,9 @@ def groups_for_user_pager(): user_item._set_groups(groups_for_user_pager) - def _get_groups_for_user(self, user_item, req_options=None): + def _get_groups_for_user( + self, user_item: UserItem, req_options: RequestOptions = None + ) -> Tuple[List[GroupItem], PaginationItem]: url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) logger.info("Populated groups for user (ID: {0})".format(user_item.id)) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index a00e7f145..cb652fbc0 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,14 +1,19 @@ +import logging +from contextlib import closing + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .resource_tagger import _ResourceTagger from .permissions_endpoint import _PermissionsEndpoint +from .resource_tagger import _ResourceTagger from .. import ViewItem, PaginationItem -from contextlib import closing -import logging - logger = logging.getLogger("tableau.endpoint.views") +from typing import Iterable, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions + class Views(QuerysetEndpoint): def __init__(self, parent_srv): @@ -18,15 +23,17 @@ def __init__(self, parent_srv): # Used because populate_preview_image functionaliy requires workbook endpoint @property - def siteurl(self): + def siteurl(self) -> str: return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/views".format(self.siteurl) @api(version="2.2") - def get(self, req_options=None, usage=False): + def get( + self, req_options: Optional["RequestOptions"] = None, usage: bool = False + ) -> Tuple[List[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -37,7 +44,7 @@ def get(self, req_options=None, usage=False): return all_view_items, pagination_item @api(version="3.1") - def get_by_id(self, view_id): + def get_by_id(self, view_id: str) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -47,7 +54,7 @@ def get_by_id(self, view_id): return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.0") - def populate_preview_image(self, view_item): + def populate_preview_image(self, view_item: ViewItem) -> None: if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) @@ -58,14 +65,14 @@ def image_fetcher(): view_item._set_preview_image(image_fetcher) logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) - def _get_preview_for_view(self, view_item): + def _get_preview_for_view(self, view_item: ViewItem) -> bytes: url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @api(version="2.5") - def populate_image(self, view_item, req_options=None): + def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -76,14 +83,14 @@ def image_fetcher(): view_item._set_image(image_fetcher) logger.info("Populated image for view (ID: {0})".format(view_item.id)) - def _get_view_image(self, view_item, req_options): + def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image @api(version="2.7") - def populate_pdf(self, view_item, req_options=None): + def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -94,14 +101,14 @@ def pdf_fetcher(): view_item._set_pdf(pdf_fetcher) logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) - def _get_view_pdf(self, view_item, req_options): + def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="2.7") - def populate_csv(self, view_item, req_options=None): + def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -112,15 +119,34 @@ def csv_fetcher(): view_item._set_csv(csv_fetcher) logger.info("Populated csv for view (ID: {0})".format(view_item.id)) - def _get_view_csv(self, view_item, req_options): + def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: csv = server_response.iter_content(1024) return csv + @api(version="3.8") + def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not view_item.id: + error = "View item missing ID." + raise MissingRequiredFieldError(error) + + def excel_fetcher(): + return self._get_view_excel(view_item, req_options) + + view_item._set_excel(excel_fetcher) + logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + excel = server_response.iter_content(1024) + return excel + @api(version="3.2") - def populate_permissions(self, item): + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -132,7 +158,7 @@ def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) # Update view. Currently only tags can be updated - def update(self, view_item): + def update(self, view_item: ViewItem) -> ViewItem: if not view_item.id: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 6f5135ac1..b28f3e5f1 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,22 +1,28 @@ +import logging + from .endpoint import Endpoint, api -from ...models import WebhookItem, PaginationItem from .. import RequestFactory - -import logging +from ...models import WebhookItem, PaginationItem logger = logging.getLogger("tableau.endpoint.webhooks") +from typing import List, Optional, TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + class Webhooks(Endpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Webhooks, self).__init__(parent_srv) @property - def baseurl(self): + def baseurl(self) -> str: 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): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -25,7 +31,7 @@ def get(self, req_options=None): return all_webhook_items, pagination_item @api(version="3.6") - def get_by_id(self, webhook_id): + def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -35,7 +41,7 @@ def get_by_id(self, webhook_id): return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.6") - def delete(self, webhook_id): + def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -44,7 +50,7 @@ def delete(self, webhook_id): logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") - def create(self, webhook_item): + def create(self, webhook_item: WebhookItem) -> WebhookItem: url = self.baseurl create_req = RequestFactory.Webhook.create_req(webhook_item) server_response = self.post_request(url, create_req) @@ -54,7 +60,7 @@ def create(self, webhook_item): return new_webhook @api(version="3.6") - def test(self, webhook_id): + def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a3f14c291..901d0e62a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,21 +1,48 @@ +import cgi +import copy +import io +import logging +import os +from contextlib import closing +from pathlib import Path +from typing import ( + List, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) + from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...models.job_item import JobItem from ...filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) +from ...models.job_item import JobItem +from ...models.revision_item import RevisionItem + +from typing import ( + List, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + Union, +) -import os -import logging -import copy -import cgi -from contextlib import closing +if TYPE_CHECKING: + from ..server import Server + from ..request_options import RequestOptions + from .. import DatasourceItem + from ...models.connection_credentials import ConnectionCredentials + from .schedules_endpoint import AddResponse # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -23,21 +50,26 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] logger = logging.getLogger("tableau.endpoint.workbooks") +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] class Workbooks(QuerysetEndpoint): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + return None + @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site @api(version="2.0") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -47,7 +79,7 @@ def get(self, req_options=None): # Get 1 workbook @api(version="2.0") - def get_by_id(self, workbook_id): + def get_by_id(self, workbook_id: str) -> WorkbookItem: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -57,7 +89,7 @@ def get_by_id(self, workbook_id): return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_id): + def refresh(self, workbook_id: str) -> JobItem: id_ = getattr(workbook_id, "id", workbook_id) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() @@ -67,7 +99,13 @@ def refresh(self, workbook_id): # create one or more extracts on 1 workbook, optionally encrypted @api(version="3.5") - def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasources=None): + def create_extract( + self, + workbook_item: WorkbookItem, + encrypt: bool = False, + includeAll: bool = True, + datasources: Optional[List["DatasourceItem"]] = None, + ) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) @@ -77,16 +115,17 @@ def create_extract(self, workbook_item, encrypt=False, includeAll=True, datasour return new_job # delete all the extracts on 1 workbook - @api(version="3.5") - def delete_extract(self, workbook_item): + @api(version="3.3") + def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True) -> None: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, None) + server_response = self.post_request(url, datasource_req) + new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Delete 1 workbook by id @api(version="2.0") - def delete(self, workbook_id): + def delete(self, workbook_id: str) -> None: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -96,7 +135,7 @@ def delete(self, workbook_id): # Update workbook @api(version="2.0") - def update(self, workbook_item): + def update(self, workbook_item: WorkbookItem) -> WorkbookItem: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -120,7 +159,7 @@ def update_conn(self, *args, **kwargs): # Update workbook_connection @api(version="2.3") - def update_connection(self, workbook_item, connection_item): + def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -135,7 +174,13 @@ def update_connection(self, workbook_item, connection_item): @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") - def download(self, workbook_id, filepath=None, include_extract=True, no_extract=None): + def download( + self, + workbook_id: str, + filepath: FilePath = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -167,18 +212,18 @@ def download(self, workbook_id, filepath=None, include_extract=True, no_extract= # Get all views of workbook @api(version="2.0") - def populate_views(self, workbook_item, usage=False): + def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher(): + def view_fetcher() -> List[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) - def _get_views_for_workbook(self, workbook_item, usage): + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" @@ -192,7 +237,7 @@ def _get_views_for_workbook(self, workbook_item, usage): # Get all connections of workbook @api(version="2.0") - def populate_connections(self, workbook_item): + def populate_connections(self, workbook_item: WorkbookItem) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -203,7 +248,9 @@ def connection_fetcher(): workbook_item._set_connections(connection_fetcher) logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) - def _get_workbook_connections(self, workbook_item, req_options=None): + def _get_workbook_connections( + self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None + ) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -211,44 +258,62 @@ def _get_workbook_connections(self, workbook_item, req_options=None): # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item, req_options=None): + def populate_pdf(self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None) -> None: if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) - def pdf_fetcher(): + def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) - def _get_wb_pdf(self, workbook_item, req_options): + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf + @api(version="3.8") + def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + if not workbook_item.id: + error = "Workbook item missing ID." + raise MissingRequiredFieldError(error) + + def pptx_fetcher() -> bytes: + return self._get_wb_pptx(workbook_item, req_options) + + workbook_item._set_powerpoint(pptx_fetcher) + logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + pptx = server_response.content + return pptx + # Get preview image of workbook @api(version="2.0") - def populate_preview_image(self, workbook_item): + def populate_preview_image(self, workbook_item: WorkbookItem) -> None: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def image_fetcher(): + def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) - def _get_wb_preview_image(self, workbook_item): + def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") - def populate_permissions(self, item): + def populate_permissions(self, item: WorkbookItem) -> None: self._permissions.populate(item) @api(version="2.0") @@ -259,20 +324,19 @@ def update_permissions(self, resource, rules): def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) - # Publishes workbook. Chunking method if file over 64MB @api(version="2.0") @parameter_added_in(as_job="3.0") @parameter_added_in(connections="2.8") def publish( self, - workbook_item, - file, - mode, - connection_credentials=None, - connections=None, - as_job=False, - hidden_views=None, - skip_connection_check=False, + workbook_item: WorkbookItem, + file: PathOrFile, + mode: str, + connection_credentials: Optional["ConnectionCredentials"] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: bool = False, + hidden_views: Optional[Sequence[str]] = None, + skip_connection_check: bool = False, ): if connection_credentials is not None: @@ -283,7 +347,7 @@ def publish( DeprecationWarning, ) - try: + if isinstance(file, (str, os.PathLike)): # Expect file to be a filepath if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -300,7 +364,7 @@ def publish( error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - except TypeError: + elif isinstance(file, (io.BytesIO, io.BufferedReader)): # Expect file to be a file object file_size = get_file_object_size(file) @@ -322,6 +386,9 @@ def publish( # This is needed when publishing the workbook in a single request filename = "{}.{}".format(workbook_item.name, file_extension) + else: + raise TypeError("file should be a filepath or file object.") + if not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -355,13 +422,16 @@ def publish( else: logger.info("Publishing {0} to server".format(filename)) - try: + if isinstance(file, (str, Path)): with open(file, "rb") as f: file_contents = f.read() - except TypeError: + elif isinstance(file, (io.BytesIO, io.BufferedReader)): file_contents = file.read() + else: + raise TypeError("file should be a filepath or file object.") + conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req( workbook_item, @@ -389,3 +459,81 @@ def publish( new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook + + # Populate workbook item's revisions + @api(version="2.3") + def populate_revisions(self, workbook_item: WorkbookItem) -> None: + if not workbook_item.id: + error = "Workbook item missing ID. Workbook must be retrieved from server first." + raise MissingRequiredFieldError(error) + + def revisions_fetcher(): + return self._get_workbook_revisions(workbook_item) + + workbook_item._set_revisions(revisions_fetcher) + logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + + def _get_workbook_revisions( + self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + server_response = self.get_request(url, req_options) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) + return revisions + + # Download 1 workbook revision by revision number + @api(version="2.3") + def download_revision( + self, + workbook_id: str, + revision_number: str, + filepath: Optional[PathOrFile] = None, + include_extract: bool = True, + no_extract: Optional[bool] = None, + ) -> str: + if not workbook_id: + error = "Workbook ID undefined." + raise ValueError(error) + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + + if no_extract is False or no_extract is True: + import warnings + + warnings.warn( + "no_extract is deprecated, use include_extract instead.", + DeprecationWarning, + ) + include_extract = not no_extract + + if not include_extract: + url += "?includeExtract=False" + + with closing(self.get_request(url, parameters={"stream": True})) as server_response: + _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + filename = to_filename(os.path.basename(params["filename"])) + + download_path = make_download_path(filepath, filename) + + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, download_path, workbook_id) + ) + return os.path.abspath(download_path) + + @api(version="2.3") + def delete_revision(self, workbook_id: str, revision_number: str) -> None: + if workbook_id is None or revision_number is None: + raise ValueError + url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) + + self.delete_request(url) + logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + + # a convenience method + @api(version="2.8") + def schedule_extract_refresh( + self, schedule_id: str, item: WorkbookItem + ) -> List["AddResponse"]: # actually should return a task + return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 3dbb830fa..64a7107aa 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ -from .request_options import RequestOptions from .filter import Filter +from .request_options import RequestOptions from .sort import Sort +import math def to_camel_case(word): @@ -15,11 +16,66 @@ def __init__(self, model): self._pagination_item = None def __iter__(self): - self._fetch_all() - return iter(self._result_cache) + # Not built to be re-entrant. Starts back at page 1, and empties + # the result cache. + self.request_options.pagenumber = 1 + self._result_cache = None + total = self.total_available + size = self.page_size + yield from self._result_cache + + # Loop through the subsequent pages. + for page in range(1, math.ceil(total / size)): + self.request_options.pagenumber = page + 1 + self._result_cache = None + self._fetch_all() + yield from self._result_cache def __getitem__(self, k): - return list(self)[k] + page = self.page_number + size = self.page_size + + # Create a range object for quick checking if k is in the cached result. + page_range = range((page - 1) * size, page * size) + + if isinstance(k, slice): + # Parse out the slice object, and assume reasonable defaults if no value provided. + step = k.step if k.step is not None else 1 + start = k.start if k.start is not None else 0 + stop = k.stop if k.stop is not None else self.total_available + + # If negative values present in slice, convert to positive values + if start < 0: + start += self.total_available + if stop < 0: + stop += self.total_available + if start < stop and step < 0: + # Since slicing is left inclusive and right exclusive, shift + # the start and stop values by 1 to keep that behavior + start, stop = stop - 1, start - 1 + slice_stop = stop if stop > 0 else None + k = slice(start, slice_stop, step) + + # Fetch items from cache if present, otherwise, recursively fetch. + k_range = range(start, stop, step) + if all(i in page_range for i in k_range): + return self._result_cache[k] + return [self[i] for i in k_range] + + if k < 0: + k += self.total_available + + if k in page_range: + # Fetch item from cache if present + return self._result_cache[k % size] + elif k in range(self.total_available): + # Otherwise, check if k is even sensible to return + self._result_cache = None + self.request_options.pagenumber = max(1, math.ceil(k / size)) + return self[k] + else: + # If k is unreasonable, raise an IndexError. + raise IndexError def _fetch_all(self): """ @@ -43,7 +99,9 @@ def page_size(self): self._fetch_all() return self._pagination_item.page_size - def filter(self, **kwargs): + def filter(self, *invalid, **kwargs): + if invalid: + raise RuntimeError(f"Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4cbea1443..7e4038979 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,12 +1,23 @@ +from os import name import xml.etree.ElementTree as ET +from typing import Any, Dict, List, Optional, Tuple, Iterable from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from tableauserverclient.models.metric_item import MetricItem + +from ..models import ConnectionItem +from ..models import DataAlertItem +from ..models import FlowItem +from ..models import ProjectItem +from ..models import SiteItem +from ..models import SubscriptionItem from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem +from ..models import WebhookItem -def _add_multipart(parts): +def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -87,22 +98,26 @@ def update_req(self, column_item): class DataAlertRequest(object): - def add_user_to_alert(self, alert_item, user_id): + def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") user_element.attrib["id"] = user_id return ET.tostring(xml_request) - def update_req(self, alert_item): + def update_req(self, alert_item: "DataAlertItem") -> bytes: xml_request = ET.Element("tsRequest") dataAlert_element = ET.SubElement(xml_request, "dataAlert") - dataAlert_element.attrib["subject"] = alert_item.subject - dataAlert_element.attrib["frequency"] = alert_item.frequency.lower() - dataAlert_element.attrib["public"] = alert_item.public + if alert_item.subject is not None: + dataAlert_element.attrib["subject"] = alert_item.subject + if alert_item.frequency is not None: + dataAlert_element.attrib["frequency"] = alert_item.frequency.lower() + if alert_item.public is not None: + dataAlert_element.attrib["public"] = str(alert_item.public).lower() owner = ET.SubElement(dataAlert_element, "owner") - owner.attrib["id"] = alert_item.owner_id + if alert_item.owner_id is not None: + owner.attrib["id"] = alert_item.owner_id return ET.tostring(xml_request) @@ -232,38 +247,8 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DQWRequest(object): - def add_req(self, dqw_item): - xml_request = ET.Element("tsRequest") - dqw_element = ET.SubElement(xml_request, "dataQualityWarning") - - dqw_element.attrib["isActive"] = str(dqw_item.active).lower() - dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() - - dqw_element.attrib["type"] = dqw_item.warning_type - - if dqw_item.message: - dqw_element.attrib["message"] = str(dqw_item.message) - - return ET.tostring(xml_request) - - def update_req(self, database_item): - xml_request = ET.Element("tsRequest") - dqw_element = ET.SubElement(xml_request, "dataQualityWarning") - - dqw_element.attrib["isActive"] = str(dqw_item.active).lower() - dqw_element.attrib["isSevere"] = str(dqw_item.severe).lower() - - dqw_element.attrib["type"] = dqw_item.warning_type - - if dqw_item.message: - dqw_element.attrib["message"] = str(dqw_item.message) - - return ET.tostring(xml_request) - - class FavoriteRequest(object): - def _add_to_req(self, id_, target_type, label): + def _add_to_req(self, id_: str, target_type: str, label: str) -> bytes: """ @@ -277,16 +262,39 @@ def _add_to_req(self, id_, target_type, label): return ET.tostring(xml_request) - def add_datasource_req(self, id_, name): + def add_datasource_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) - def add_project_req(self, id_, name): + def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") + return self._add_to_req(id_, FavoriteItem.Type.Flow, name) + + def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Project, name) - def add_view_req(self, id_, name): + def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.View, name) - def add_workbook_req(self, id_, name): + def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + if id_ is None: + raise ValueError("id must exist to add to favorites") + if name is None: + raise ValueError("Name must exist to add to favorites.") return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) @@ -300,10 +308,11 @@ def chunk_req(self, chunk): class FlowRequest(object): - def _generate_xml(self, flow_item, connections=None): + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") - flow_element.attrib["name"] = flow_item.name + if flow_item.name is not None: + flow_element.attrib["name"] = flow_item.name project_element = ET.SubElement(flow_element, "project") project_element.attrib["id"] = flow_item.project_id @@ -313,7 +322,7 @@ def _generate_xml(self, flow_item, connections=None): _add_connections_element(connections_element, connection) return ET.tostring(xml_request) - def update_req(self, flow_item): + def update_req(self, flow_item: "FlowItem") -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.project_id: @@ -325,7 +334,13 @@ def update_req(self, flow_item): return ET.tostring(xml_request) - def publish_req(self, flow_item, filename, file_contents, connections=None): + def publish_req( + self, + flow_item: "FlowItem", + filename: str, + file_contents: bytes, + connections: Optional[List["ConnectionItem"]] = None, + ) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -334,7 +349,7 @@ def publish_req(self, flow_item, filename, file_contents, connections=None): } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None): + def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} @@ -342,24 +357,30 @@ def publish_req_chunked(self, flow_item, connections=None): class GroupRequest(object): - def add_user_req(self, user_id): + def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") user_element.attrib["id"] = user_id return ET.tostring(xml_request) - def create_local_req(self, group_item): + def create_local_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") if group_item.minimum_site_role is not None: group_element.attrib["minimumSiteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def create_ad_req(self, group_item): + def create_ad_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") import_element = ET.SubElement(group_element, "import") import_element.attrib["source"] = "ActiveDirectory" if group_item.domain_name is None: @@ -373,7 +394,7 @@ def create_ad_req(self, group_item): import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item, default_site_role=None): + def update_req(self, group_item: GroupItem, default_site_role: Optional[str] = None) -> bytes: # (1/8/2021): Deprecated starting v0.15 if default_site_role is not None: import warnings @@ -388,13 +409,20 @@ def update_req(self, group_item, default_site_role=None): xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") - group_element.attrib["name"] = group_item.name + + if group_item.name is not None: + group_element.attrib["name"] = group_item.name + else: + raise ValueError("Group name must be populated") if group_item.domain_name is not None and group_item.domain_name != "local": # Import element is only accepted in the request for AD groups import_element = ET.SubElement(group_element, "import") import_element.attrib["source"] = "ActiveDirectory" import_element.attrib["domainName"] = group_item.domain_name - import_element.attrib["siteRole"] = group_item.minimum_site_role + if isinstance(group_item.minimum_site_role, str): + import_element.attrib["siteRole"] = group_item.minimum_site_role + else: + raise ValueError("Minimum site role must be provided.") if group_item.license_mode is not None: import_element.attrib["grantLicenseMode"] = group_item.license_mode else: @@ -406,7 +434,7 @@ def update_req(self, group_item, default_site_role=None): class PermissionRequest(object): - def add_req(self, rules): + def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -428,7 +456,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): class ProjectRequest(object): - def update_req(self, project_item): + def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") if project_item.name: @@ -441,7 +469,7 @@ def update_req(self, project_item): project_element.attrib["parentProjectId"] = project_item.parent_id return ET.tostring(xml_request) - def create_req(self, project_item): + def create_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") project_element.attrib["name"] = project_item.name @@ -504,7 +532,7 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): + def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: """ @@ -513,6 +541,8 @@ def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ + if not isinstance(id_, str): + raise ValueError(f"id_ should be a string, reeceived: {type(id_)}") xml_request = ET.Element("tsRequest") task_element = ET.SubElement(xml_request, "task") task = ET.SubElement(task_element, task_type) @@ -521,15 +551,18 @@ def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): return ET.tostring(xml_request) - def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + def add_workbook_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + def add_datasource_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "datasource", task_type) + def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlow) -> bytes: + return self._add_to_req(id_, "flow", task_type) + class SiteRequest(object): - def update_req(self, site_item): + def update_req(self, site_item: "SiteItem"): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -635,7 +668,7 @@ def update_req(self, site_item): return ET.tostring(xml_request) - def create_req(self, site_item): + def create_req(self, site_item: "SiteItem"): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -769,7 +802,7 @@ def add_req(self, tag_set): class UserRequest(object): - def update_req(self, user_item, password): + def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") if user_item.fullname: @@ -785,11 +818,18 @@ def update_req(self, user_item, password): user_element.attrib["password"] = password return ET.tostring(xml_request) - def add_req(self, user_item): + def add_req(self, user_item: UserItem) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") - user_element.attrib["name"] = user_item.name - user_element.attrib["siteRole"] = user_item.site_role + if isinstance(user_item.name, str): + user_element.attrib["name"] = user_item.name + else: + raise ValueError(f"{user_item} missing name.") + if isinstance(user_item.site_role, str): + user_element.attrib["siteRole"] = user_item.site_role + else: + raise ValueError(f"{user_item} must have site role populated.") + if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting return ET.tostring(xml_request) @@ -823,8 +863,19 @@ def _generate_xml( _add_connections_element(connections_element, connection) if hidden_views is not None: + import warnings + + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + "the hidden_views parameter should now be set on the workbook directly", + DeprecationWarning, + ) + if workbook_item.hidden_views is None: + workbook_item.hidden_views = hidden_views + + if workbook_item.hidden_views is not None: views_element = ET.SubElement(workbook_element, "views") - for view_name in hidden_views: + for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) return ET.tostring(xml_request) @@ -930,7 +981,7 @@ def run_req(self, xml_request, task_item): class SubscriptionRequest(object): @_tsrequest_wrapped - def create_req(self, xml_request, subscription_item): + def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") # Main attributes @@ -963,7 +1014,7 @@ def create_req(self, xml_request, subscription_item): return ET.tostring(xml_request) @_tsrequest_wrapped - def update_req(self, xml_request, subscription_item): + def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription = ET.SubElement(xml_request, "subscription") # Main attributes @@ -1000,17 +1051,46 @@ def empty_req(self, xml_request): class WebhookRequest(object): @_tsrequest_wrapped - def create_req(self, xml_request, webhook_item): + def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") - webhook.attrib["name"] = webhook_item.name + if isinstance(webhook_item.name, str): + webhook.attrib["name"] = webhook_item.name + else: + raise ValueError(f"Name must be provided for {webhook_item}") source = ET.SubElement(webhook, "webhook-source") - ET.SubElement(source, webhook_item._event) + if isinstance(webhook_item._event, str): + ET.SubElement(source, webhook_item._event) + else: + raise ValueError(f"_event for Webhook must be provided. {webhook_item}") destination = ET.SubElement(webhook, "webhook-destination") post = ET.SubElement(destination, "webhook-destination-http") post.attrib["method"] = "POST" - post.attrib["url"] = webhook_item.url + if isinstance(webhook_item.url, str): + post.attrib["url"] = webhook_item.url + else: + raise ValueError(f"URL must be provided on {webhook_item}") + + return ET.tostring(xml_request) + + +class MetricRequest: + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: + metric_element = ET.SubElement(xml_request, "metric") + if metric_item.id is not None: + metric_element.attrib["id"] = metric_item.id + if metric_item.name is not None: + metric_element.attrib["name"] = metric_item.name + if metric_item.description is not None: + metric_element.attrib["description"] = metric_item.description + if metric_item.suspended is not None: + metric_element.attrib["suspended"] = str(metric_item.suspended).lower() + if metric_item.project_id is not None: + ET.SubElement(metric_element, "project", {"id": metric_item.project_id}) + if metric_item.owner_id is not None: + ET.SubElement(metric_element, "owner", {"id": metric_item.owner_id}) return ET.tostring(xml_request) @@ -1028,6 +1108,7 @@ class RequestFactory(object): Fileupload = FileuploadRequest() Flow = FlowRequest() Group = GroupRequest() + Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() Schedule = ScheduleRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 3047691a9..36ffccd8e 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -48,6 +48,7 @@ class Field: OwnerName = "ownerName" Progress = "progress" ProjectName = "projectName" + PublishSamples = "publishSamples" SiteRole = "siteRole" Subtitle = "subtitle" Tags = "tags" diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 56fc47849..4522bc272 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,7 +1,8 @@ -import xml.etree.ElementTree as ET +from distutils.version import LooseVersion as Version +import urllib3 +import requests +from defusedxml.ElementTree import fromstring -from .exceptions import NotSignedInError -from ..namespace import Namespace from .endpoint import ( Sites, Views, @@ -25,19 +26,19 @@ Favorites, DataAlerts, Fileuploads, - FlowRuns + FlowRuns, + Metrics, ) from .endpoint.exceptions import ( EndpointUnavailableError, ServerInfoEndpointNotFoundError, ) +from .exceptions import NotSignedInError +from ..namespace import Namespace import requests -try: - from distutils2.version import NormalizedVersion as Version -except ImportError: - from distutils.version import LooseVersion as Version +from distutils.version import LooseVersion as Version _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", @@ -54,7 +55,7 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=False): + def __init__(self, server_address, use_server_version=True, http_options=None): self._server_address = server_address self._auth_token = None self._site_id = None @@ -87,12 +88,18 @@ def __init__(self, server_address, use_server_version=False): self.fileuploads = Fileuploads(self) self._namespace = Namespace() self.flow_runs = FlowRuns(self) + self.metrics = Metrics(self) + + if http_options: + self.add_http_options(http_options) if use_server_version: self.use_server_version() def add_http_options(self, options_dict): self._http_options.update(options_dict) + if options_dict.get("verify") == False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def clear_http_options(self): self._http_options = dict() @@ -110,7 +117,7 @@ def _set_auth(self, site_id, user_id, auth_token): def _get_legacy_version(self): response = self._session.get(self.server_address + "/auth?format=xml") - info_xml = ET.fromstring(response.content) + info_xml = fromstring(response.content) prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 return version diff --git a/test/_utils.py b/test/_utils.py index 626838f23..8527aaf8c 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,8 +1,8 @@ -from contextlib import contextmanager -import unittest import os.path +import unittest +from contextlib import contextmanager -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") def asset(filename): @@ -10,8 +10,8 @@ def asset(filename): def read_xml_asset(filename): - with open(asset(filename), 'rb') as f: - return f.read().decode('utf-8') + with open(asset(filename), "rb") as f: + return f.read().decode("utf-8") def read_xml_assets(*args): @@ -28,7 +28,7 @@ def sleep_mock(interval): def get_time(): return mock_time - + try: patch = unittest.mock.patch except AttributeError: diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/datasource_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/flow_get_by_id.xml b/test/assets/flow_get_by_id.xml new file mode 100644 index 000000000..d1c626105 --- /dev/null +++ b/test/assets/flow_get_by_id.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_get.xml b/test/assets/metrics_get.xml new file mode 100644 index 000000000..566af1074 --- /dev/null +++ b/test/assets/metrics_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_get_by_id.xml b/test/assets/metrics_get_by_id.xml new file mode 100644 index 000000000..30652da0f --- /dev/null +++ b/test/assets/metrics_get_by_id.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/metrics_update.xml b/test/assets/metrics_update.xml new file mode 100644 index 000000000..30652da0f --- /dev/null +++ b/test/assets/metrics_update.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/populate_excel.xlsx b/test/assets/populate_excel.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3cf6115c7e11e2f8b54eb956c471fab1716b1411 GIT binary patch literal 6623 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 literal 0 HcmV?d00001 diff --git a/test/assets/populate_powerpoint.pptx b/test/assets/populate_powerpoint.pptx new file mode 100644 index 0000000000000000000000000000000000000000..dbf979c063261cfb70624dd7c30c57b647398d72 GIT binary patch literal 363648 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�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

XYA^00F`_h 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

4`LS|A9&53qCBJvIs7*|42j+*-mANyr{C+wl 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 literal 0 HcmV?d00001 diff --git a/test/assets/request_option_slicing_queryset.xml b/test/assets/request_option_slicing_queryset.xml new file mode 100644 index 000000000..34708c911 --- /dev/null +++ b/test/assets/request_option_slicing_queryset.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_add_flow.xml b/test/assets/schedule_add_flow.xml new file mode 100644 index 000000000..9934c38e5 --- /dev/null +++ b/test/assets/schedule_add_flow.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_get_by_id.xml b/test/assets/schedule_get_by_id.xml new file mode 100644 index 000000000..943416beb --- /dev/null +++ b/test/assets/schedule_get_by_id.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml new file mode 100644 index 000000000..598c8ad45 --- /dev/null +++ b/test/assets/workbook_revision.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 3dbf87737..40255f627 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -1,89 +1,93 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -SIGN_IN_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in.xml') -SIGN_IN_IMPERSONATE_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in_impersonate.xml') -SIGN_IN_ERROR_XML = os.path.join(TEST_ASSET_DIR, 'auth_sign_in_error.xml') +SIGN_IN_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in.xml") +SIGN_IN_IMPERSONATE_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_impersonate.xml") +SIGN_IN_ERROR_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_error.xml") class AuthTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.auth.baseurl def test_sign_in(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.TableauAuth('testuser', 'password', site_id='Samples') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth("testuser", "password", site_id="Samples") self.server.auth.sign_in(tableau_auth) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_with_personal_access_tokens(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', - personal_access_token='Random123Generated', site_id='Samples') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name="mytoken", personal_access_token="Random123Generated", site_id="Samples" + ) self.server.auth.sign_in(tableau_auth) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_impersonate(self): - with open(SIGN_IN_IMPERSONATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_IMPERSONATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - tableau_auth = TSC.TableauAuth('testuser', 'password', - user_id_to_impersonate='dd2239f6-ddf1-4107-981a-4cf94e415794') + m.post(self.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth( + "testuser", "password", user_id_to_impersonate="dd2239f6-ddf1-4107-981a-4cf94e415794" + ) self.server.auth.sign_in(tableau_auth) - self.assertEqual('MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3', self.server.auth_token) - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', self.server.site_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', self.server.user_id) + self.assertEqual("MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3", self.server.auth_token) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", self.server.site_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", self.server.user_id) def test_sign_in_error(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth('testuser', 'wrongpassword') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name='mytoken', personal_access_token='invalid') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): - with open(SIGN_IN_ERROR_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth('', '') + m.post(self.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("", "") self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/signin', text=response_xml) - m.post(self.baseurl + '/signout', text='') - tableau_auth = TSC.TableauAuth('testuser', 'password') + m.post(self.baseurl + "/signin", text=response_xml) + m.post(self.baseurl + "/signout", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") self.server.auth.sign_in(tableau_auth) self.server.auth.sign_out() @@ -92,33 +96,33 @@ def test_sign_out(self): self.assertIsNone(self.server._user_id) def test_switch_site(self): - self.server.version = '2.6' + self.server.version = "2.6" baseurl = self.server.auth.baseurl - site_id, user_id, auth_token = list('123') + site_id, user_id, auth_token = list("123") self.server._set_auth(site_id, user_id, auth_token) - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(baseurl + '/switchSite', text=response_xml) - site = TSC.SiteItem('Samples', 'Samples') + m.post(baseurl + "/switchSite", text=response_xml) + site = TSC.SiteItem("Samples", "Samples") self.server.auth.switch_site(site) - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_revoke_all_server_admin_tokens(self): self.server.version = "3.10" baseurl = self.server.auth.baseurl - with open(SIGN_IN_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(baseurl + '/signin', text=response_xml) - m.post(baseurl + '/revokeAllServerAdminTokens', text='') - tableau_auth = TSC.TableauAuth('testuser', 'password') + m.post(baseurl + "/signin", text=response_xml) + m.post(baseurl + "/revokeAllServerAdminTokens", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") self.server.auth.sign_in(tableau_auth) self.server.auth.revoke_all_server_admin_tokens() - self.assertEqual('eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l', self.server.auth_token) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', self.server.site_id) - self.assertEqual('1a96d216-e9b8-497b-a82a-0b899a965e01', self.server.user_id) + self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) diff --git a/test/test_data_acceleration_report.py b/test/test_data_acceleration_report.py index 7722bf230..8f9f5a49e 100644 --- a/test/test_data_acceleration_report.py +++ b/test/test_data_acceleration_report.py @@ -1,20 +1,20 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'data_acceleration_report.xml' +GET_XML = "data_acceleration_report.xml" class DataAccelerationReportTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.8" self.baseurl = self.server.data_acceleration_report.baseurl diff --git a/test/test_dataalert.py b/test/test_dataalert.py index 7822d3000..d9e00a9db 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -1,100 +1,97 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'data_alerts_get.xml' -GET_BY_ID_XML = 'data_alerts_get_by_id.xml' -ADD_USER_TO_ALERT = 'data_alerts_add_user.xml' -UPDATE_XML = 'data_alerts_update.xml' +GET_XML = "data_alerts_get.xml" +GET_BY_ID_XML = "data_alerts_get_by_id.xml" +ADD_USER_TO_ALERT = "data_alerts_add_user.xml" +UPDATE_XML = "data_alerts_update.xml" class DataAlertTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.2" self.baseurl = self.server.data_alerts.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_alerts, pagination_item = self.server.data_alerts.get() self.assertEqual(1, pagination_item.total_available) - self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', all_alerts[0].id) - self.assertEqual('Data Alert test', all_alerts[0].subject) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].creatorId) - self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].createdAt) - self.assertEqual('2020-08-10T23:17:06Z', all_alerts[0].updatedAt) - self.assertEqual('Daily', all_alerts[0].frequency) - self.assertEqual('true', all_alerts[0].public) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_alerts[0].owner_id) - self.assertEqual('Bob', all_alerts[0].owner_name) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_alerts[0].view_id) - self.assertEqual('ENDANGERED SAFARI', all_alerts[0].view_name) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_alerts[0].workbook_id) - self.assertEqual('Safari stats', all_alerts[0].workbook_name) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_alerts[0].project_id) - self.assertEqual('Default', all_alerts[0].project_name) - - def test_get_by_id(self): + self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", all_alerts[0].id) + self.assertEqual("Data Alert test", all_alerts[0].subject) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].creatorId) + self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].createdAt) + self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].updatedAt) + self.assertEqual("Daily", all_alerts[0].frequency) + self.assertEqual("true", all_alerts[0].public) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].owner_id) + self.assertEqual("Bob", all_alerts[0].owner_name) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_alerts[0].view_id) + self.assertEqual("ENDANGERED SAFARI", all_alerts[0].view_name) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_alerts[0].workbook_id) + self.assertEqual("Safari stats", all_alerts[0].workbook_name) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_alerts[0].project_id) + self.assertEqual("Default", all_alerts[0].project_name) + + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) - alert = self.server.data_alerts.get_by_id('5ea59b45-e497-5673-8809-bfe213236f75') + m.get(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) + alert = self.server.data_alerts.get_by_id("5ea59b45-e497-5673-8809-bfe213236f75") self.assertTrue(isinstance(alert.recipients, list)) self.assertEqual(len(alert.recipients), 1) - self.assertEqual(alert.recipients[0], 'dd2239f6-ddf1-4107-981a-4cf94e415794') + self.assertEqual(alert.recipients[0], "dd2239f6-ddf1-4107-981a-4cf94e415794") - def test_update(self): + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/5ea59b45-e497-5673-8809-bfe213236f75', text=response_xml) + m.put(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) single_alert = TSC.DataAlertItem() - single_alert._id = '5ea59b45-e497-5673-8809-bfe213236f75' - single_alert._subject = 'Data Alert test' - single_alert._frequency = 'Daily' - single_alert._public = "true" + single_alert._id = "5ea59b45-e497-5673-8809-bfe213236f75" + single_alert._subject = "Data Alert test" + single_alert._frequency = "Daily" + single_alert._public = True single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" single_alert = self.server.data_alerts.update(single_alert) - self.assertEqual('5ea59b45-e497-5673-8809-bfe213236f75', single_alert.id) - self.assertEqual('Data Alert test', single_alert.subject) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.creatorId) - self.assertEqual('2020-08-10T23:17:06Z', single_alert.createdAt) - self.assertEqual('2020-08-10T23:17:06Z', single_alert.updatedAt) - self.assertEqual('Daily', single_alert.frequency) - self.assertEqual('true', single_alert.public) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_alert.owner_id) - self.assertEqual('Bob', single_alert.owner_name) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_alert.view_id) - self.assertEqual('ENDANGERED SAFARI', single_alert.view_name) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', single_alert.workbook_id) - self.assertEqual('Safari stats', single_alert.workbook_name) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', single_alert.project_id) - self.assertEqual('Default', single_alert.project_name) - - def test_add_user_to_alert(self): + self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", single_alert.id) + self.assertEqual("Data Alert test", single_alert.subject) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.creatorId) + self.assertEqual("2020-08-10T23:17:06Z", single_alert.createdAt) + self.assertEqual("2020-08-10T23:17:06Z", single_alert.updatedAt) + self.assertEqual("Daily", single_alert.frequency) + self.assertEqual("true", single_alert.public) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.owner_id) + self.assertEqual("Bob", single_alert.owner_name) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_alert.view_id) + self.assertEqual("ENDANGERED SAFARI", single_alert.view_name) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", single_alert.workbook_id) + self.assertEqual("Safari stats", single_alert.workbook_name) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", single_alert.project_id) + self.assertEqual("Default", single_alert.project_name) + + def test_add_user_to_alert(self) -> None: response_xml = read_xml_asset(ADD_USER_TO_ALERT) single_alert = TSC.DataAlertItem() - single_alert._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' - in_user = TSC.UserItem('Bob', TSC.UserItem.Roles.Explorer) - in_user._id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + single_alert._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + in_user = TSC.UserItem("Bob", TSC.UserItem.Roles.Explorer) + in_user._id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.post(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users', text=response_xml) + m.post(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users", text=response_xml) out_user = self.server.data_alerts.add_user_to_alert(single_alert, in_user) @@ -102,14 +99,14 @@ def test_add_user_to_alert(self): self.assertEqual(out_user.name, in_user.name) self.assertEqual(out_user.site_role, in_user.site_role) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.data_alerts.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.data_alerts.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") - def test_delete_user_from_alert(self): - alert_id = '5ea59b45-e497-5673-8809-bfe213236f75' - user_id = '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7' + def test_delete_user_from_alert(self) -> None: + alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" + user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + '/{0}/users/{1}'.format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_database.py b/test/test_database.py index e7c6a6fb6..3fd2c9a67 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -1,25 +1,22 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset - -GET_XML = 'database_get.xml' -POPULATE_PERMISSIONS_XML = 'database_populate_permissions.xml' -UPDATE_XML = 'database_update.xml' +from ._utils import read_xml_asset, asset + +GET_XML = "database_get.xml" +POPULATE_PERMISSIONS_XML = "database_populate_permissions.xml" +UPDATE_XML = "database_update.xml" GET_DQW_BY_CONTENT = "dqw_by_content_type.xml" class DatabaseTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.databases.baseurl @@ -31,64 +28,72 @@ def test_get(self): all_databases, pagination_item = self.server.databases.get() self.assertEqual(5, pagination_item.total_available) - self.assertEqual('5ea59b45-e497-4827-8809-bfe213236f75', all_databases[0].id) - self.assertEqual('hyper', all_databases[0].connection_type) - self.assertEqual('hyper_0.hyper', all_databases[0].name) - - self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', all_databases[1].id) - self.assertEqual('sqlserver', all_databases[1].connection_type) - self.assertEqual('testv1', all_databases[1].name) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_databases[1].contact_id) + self.assertEqual("5ea59b45-e497-4827-8809-bfe213236f75", all_databases[0].id) + self.assertEqual("hyper", all_databases[0].connection_type) + self.assertEqual("hyper_0.hyper", all_databases[0].name) + + self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", all_databases[1].id) + self.assertEqual("sqlserver", all_databases[1].connection_type) + self.assertEqual("testv1", all_databases[1].name) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_databases[1].contact_id) self.assertEqual(True, all_databases[1].certified) def test_update(self): response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/23591f2c-4802-4d6a-9e28-574a8ea9bc4c', text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database.contact_id = '9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0' - single_database._id = '23591f2c-4802-4d6a-9e28-574a8ea9bc4c' + m.put(self.baseurl + "/23591f2c-4802-4d6a-9e28-574a8ea9bc4c", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database.contact_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + single_database._id = "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" single_database.certified = True single_database.certification_note = "Test" single_database = self.server.databases.update(single_database) - self.assertEqual('23591f2c-4802-4d6a-9e28-574a8ea9bc4c', single_database.id) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', single_database.contact_id) + self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", single_database.id) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", single_database.contact_id) self.assertEqual(True, single_database.certified) self.assertEqual("Test", single_database.certification_note) def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.databases.populate_permissions(single_database) permissions = single_database.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }, + ) def test_populate_data_quality_warning(self): - with open(asset(GET_DQW_BY_CONTENT), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(GET_DQW_BY_CONTENT), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.databases._data_quality_warnings.baseurl + '/94441d26-9a52-4a42-b0fb-3f94792d1aac', - text=response_xml) - single_database = TSC.DatabaseItem('test') - single_database._id = '94441d26-9a52-4a42-b0fb-3f94792d1aac' + m.get( + self.server.databases._data_quality_warnings.baseurl + "/94441d26-9a52-4a42-b0fb-3f94792d1aac", + text=response_xml, + ) + single_database = TSC.DatabaseItem("test") + single_database._id = "94441d26-9a52-4a42-b0fb-3f94792d1aac" self.server.databases.populate_dqw(single_database) dqws = single_database.dqws @@ -104,5 +109,5 @@ def test_populate_data_quality_warning(self): def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.databases.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.databases.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") diff --git a/test/test_datasource.py b/test/test_datasource.py index 52a5eabe3..46378201f 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,85 +1,87 @@ -from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads +import os +import tempfile import unittest from io import BytesIO -import os -import requests_mock -import xml.etree.ElementTree as ET from zipfile import ZipFile +import requests_mock +from defusedxml.ElementTree import fromstring + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, read_xml_assets, asset - -ADD_TAGS_XML = 'datasource_add_tags.xml' -GET_XML = 'datasource_get.xml' -GET_EMPTY_XML = 'datasource_get_empty.xml' -GET_BY_ID_XML = 'datasource_get_by_id.xml' -POPULATE_CONNECTIONS_XML = 'datasource_populate_connections.xml' -POPULATE_PERMISSIONS_XML = 'datasource_populate_permissions.xml' -PUBLISH_XML = 'datasource_publish.xml' -PUBLISH_XML_ASYNC = 'datasource_publish_async.xml' -REFRESH_XML = 'datasource_refresh.xml' -UPDATE_XML = 'datasource_update.xml' -UPDATE_HYPER_DATA_XML = 'datasource_data_update.xml' -UPDATE_CONNECTION_XML = 'datasource_connection_update.xml' +ADD_TAGS_XML = "datasource_add_tags.xml" +GET_XML = "datasource_get.xml" +GET_EMPTY_XML = "datasource_get_empty.xml" +GET_BY_ID_XML = "datasource_get_by_id.xml" +POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" +POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" +PUBLISH_XML = "datasource_publish.xml" +PUBLISH_XML_ASYNC = "datasource_publish_async.xml" +REFRESH_XML = "datasource_refresh.xml" +REVISION_XML = "datasource_revision.xml" +UPDATE_XML = "datasource_update.xml" +UPDATE_HYPER_DATA_XML = "datasource_data_update.xml" +UPDATE_CONNECTION_XML = "datasource_connection_update.xml" class DatasourceTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.datasources.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_datasources, pagination_item = self.server.datasources.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', all_datasources[0].id) - self.assertEqual('dataengine', all_datasources[0].datasource_type) - self.assertEqual('SampleDsDescription', all_datasources[0].description) - self.assertEqual('SampleDS', all_datasources[0].content_url) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(all_datasources[0].created_at)) - self.assertEqual('2016-08-11T21:34:17Z', format_datetime(all_datasources[0].updated_at)) - self.assertEqual('default', all_datasources[0].project_name) - self.assertEqual('SampleDS', all_datasources[0].name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[0].project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[0].owner_id) - self.assertEqual('https://web.com', all_datasources[0].webpage_url) + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", all_datasources[0].id) + self.assertEqual("dataengine", all_datasources[0].datasource_type) + self.assertEqual("SampleDsDescription", all_datasources[0].description) + self.assertEqual("SampleDS", all_datasources[0].content_url) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at)) + self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at)) + self.assertEqual("default", all_datasources[0].project_name) + self.assertEqual("SampleDS", all_datasources[0].name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[0].project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[0].owner_id) + self.assertEqual("https://web.com", all_datasources[0].webpage_url) self.assertFalse(all_datasources[0].encrypt_extracts) self.assertTrue(all_datasources[0].has_extracts) self.assertFalse(all_datasources[0].use_remote_query_agent) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', all_datasources[1].id) - self.assertEqual('dataengine', all_datasources[1].datasource_type) - self.assertEqual('description Sample', all_datasources[1].description) - self.assertEqual('Sampledatasource', all_datasources[1].content_url) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].created_at)) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(all_datasources[1].updated_at)) - self.assertEqual('default', all_datasources[1].project_name) - self.assertEqual('Sample datasource', all_datasources[1].name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_datasources[1].project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_datasources[1].owner_id) - self.assertEqual(set(['world', 'indicators', 'sample']), all_datasources[1].tags) - self.assertEqual('https://page.com', all_datasources[1].webpage_url) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", all_datasources[1].id) + self.assertEqual("dataengine", all_datasources[1].datasource_type) + self.assertEqual("description Sample", all_datasources[1].description) + self.assertEqual("Sampledatasource", all_datasources[1].content_url) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at)) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at)) + self.assertEqual("default", all_datasources[1].project_name) + self.assertEqual("Sample datasource", all_datasources[1].name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) + self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) self.assertTrue(all_datasources[1].use_remote_query_agent) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.datasources.get) - def test_get_empty(self): + def test_get_empty(self) -> None: response_xml = read_xml_asset(GET_EMPTY_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) @@ -88,33 +90,33 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_datasources) - def test_get_by_id(self): + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = self.server.datasources.get_by_id('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') - - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) - self.assertEqual('dataengine', single_datasource.datasource_type) - self.assertEqual('abc description xyz', single_datasource.description) - self.assertEqual('Sampledatasource', single_datasource.content_url) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.created_at)) - self.assertEqual('2016-08-04T21:31:55Z', format_datetime(single_datasource.updated_at)) - self.assertEqual('default', single_datasource.project_name) - self.assertEqual('Sample datasource', single_datasource.name) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_datasource.project_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_datasource.owner_id) - self.assertEqual(set(['world', 'indicators', 'sample']), single_datasource.tags) + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = self.server.datasources.get_by_id("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) + self.assertEqual("dataengine", single_datasource.datasource_type) + self.assertEqual("abc description xyz", single_datasource.description) + self.assertEqual("Sampledatasource", single_datasource.content_url) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.created_at)) + self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.updated_at)) + self.assertEqual("default", single_datasource.project_name) + self.assertEqual("Sample datasource", single_datasource.name) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) + self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) - def test_update(self): + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'Sample datasource') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._content_url = 'Sampledatasource' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "Sample datasource") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._content_url = "Sampledatasource" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" single_datasource.certified = True single_datasource.certification_note = "Warning, here be dragons." updated_datasource = self.server.datasources.update(single_datasource) @@ -127,476 +129,557 @@ def test_update(self): self.assertEqual(updated_datasource.certified, single_datasource.certified) self.assertEqual(updated_datasource.certification_note, single_datasource.certification_note) - def test_update_copy_fields(self): - with open(asset(UPDATE_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update_copy_fields(self) -> None: + with open(asset(UPDATE_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - single_datasource._project_name = 'Tester' + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._project_name = "Tester" updated_datasource = self.server.datasources.update(single_datasource) self.assertEqual(single_datasource.tags, updated_datasource.tags) self.assertEqual(single_datasource._project_name, updated_datasource._project_name) - def test_update_tags(self): + def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags', text=add_tags_xml) - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b', status_code=204) - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d', status_code=204) - m.put(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', text=update_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - single_datasource._initial_tags.update(['a', 'b', 'c', 'd']) - single_datasource.tags.update(['a', 'c', 'e']) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._initial_tags.update(["a", "b", "c", "d"]) + single_datasource.tags.update(["a", "c", "e"]) updated_datasource = self.server.datasources.update(single_datasource) self.assertEqual(single_datasource.tags, updated_datasource.tags) self.assertEqual(single_datasource._initial_tags, updated_datasource._initial_tags) - def test_populate_connections(self): + def test_populate_connections(self) -> None: response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) connections = single_datasource.connections self.assertTrue(connections) ds1, ds2 = connections - self.assertEqual('be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', ds1.id) - self.assertEqual('textscan', ds1.connection_type) - self.assertEqual('forty-two.net', ds1.server_address) - self.assertEqual('duo', ds1.username) + self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) + self.assertEqual("textscan", ds1.connection_type) + self.assertEqual("forty-two.net", ds1.server_address) + self.assertEqual("duo", ds1.username) self.assertEqual(True, ds1.embed_password) - self.assertEqual('970e24bc-e200-4841-a3e9-66e7d122d77e', ds2.id) - self.assertEqual('sqlserver', ds2.connection_type) - self.assertEqual('database.com', ds2.server_address) - self.assertEqual('heero', ds2.username) + self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) + self.assertEqual("sqlserver", ds2.connection_type) + self.assertEqual("database.com", ds2.server_address) + self.assertEqual("heero", ds2.username) self.assertEqual(False, ds2.embed_password) - def test_update_connection(self): + def test_update_connection(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=populate_xml) - m.put(self.baseurl + - '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488', - text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) + m.put( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", + text=response_xml, + ) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) - connection = single_datasource.connections[0] - connection.server_address = 'bar' - connection.server_port = '9876' - connection.username = 'foo' + connection = single_datasource.connections[0] # type: ignore[index] + connection.server_address = "bar" + connection.server_port = "9876" + connection.username = "foo" 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.assertEqual('bar', new_connection.server_address) - self.assertEqual('9876', new_connection.server_port) - self.assertEqual('foo', new_connection.username) + 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): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_datasource = TSC.DatasourceItem('1d0304cd-3796-429f-b815-7258370b9b74', 'test') - single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.datasources.populate_permissions(single_datasource) permissions = single_datasource.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }) - - def test_publish(self): + self.assertEqual(permissions[0].grantee.tag_name, "group") # type: ignore[index] + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") # type: ignore[index] + self.assertDictEqual( + permissions[0].capabilities, # type: ignore[index] + { + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") # type: ignore[index] + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") # type: ignore[index] + self.assertDictEqual( + permissions[1].capabilities, # type: ignore[index] + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + }, + ) + + def test_publish(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - new_datasource = self.server.datasources.publish(new_datasource, - asset('SampleDS.tds'), - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_a_non_packaged_file_object(self): + new_datasource = self.server.datasources.publish(new_datasource, asset("SampleDS.tds"), mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_a_non_packaged_file_object(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - with open(asset('SampleDS.tds'), 'rb') as file_object: - new_datasource = self.server.datasources.publish(new_datasource, - file_object, - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_a_packaged_file_object(self): + with open(asset("SampleDS.tds"), "rb") as file_object: + new_datasource = self.server.datasources.publish(new_datasource, file_object, mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_a_packaged_file_object(self) -> None: response_xml = read_xml_asset(PUBLISH_XML) with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew # Create a dummy tdsx file in memory with BytesIO() as zip_archive: - with ZipFile(zip_archive, 'w') as zf: - zf.write(asset('SampleDS.tds')) + with ZipFile(zip_archive, "w") as zf: + zf.write(asset("SampleDS.tds")) zip_archive.seek(0) - new_datasource = self.server.datasources.publish(new_datasource, - zip_archive, - mode=publish_mode) - - self.assertEqual('e76a1461-3b1d-4588-bf1b-17551a879ad9', new_datasource.id) - self.assertEqual('SampleDS', new_datasource.name) - self.assertEqual('SampleDS', new_datasource.content_url) - self.assertEqual('dataengine', new_datasource.datasource_type) - self.assertEqual('2016-08-11T21:22:40Z', format_datetime(new_datasource.created_at)) - self.assertEqual('2016-08-17T23:37:08Z', format_datetime(new_datasource.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_datasource.project_id) - self.assertEqual('default', new_datasource.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) - - def test_publish_async(self): + new_datasource = self.server.datasources.publish(new_datasource, zip_archive, mode=publish_mode) + + self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) + self.assertEqual("SampleDS", new_datasource.name) + self.assertEqual("SampleDS", new_datasource.content_url) + self.assertEqual("dataengine", new_datasource.datasource_type) + self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) + self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) + self.assertEqual("default", new_datasource.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) + + def test_publish_async(self) -> None: 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(baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'SampleDS') + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") publish_mode = self.server.PublishMode.CreateNew - new_job = self.server.datasources.publish(new_datasource, - asset('SampleDS.tds'), - mode=publish_mode, - as_job=True) + new_job = self.server.datasources.publish( + new_datasource, asset("SampleDS.tds"), mode=publish_mode, as_job=True + ) - self.assertEqual('9a373058-af5f-4f83-8662-98b3e0228a73', new_job.id) - self.assertEqual('PublishDatasource', new_job.type) - self.assertEqual('0', new_job.progress) - self.assertEqual('2018-06-30T00:54:54Z', format_datetime(new_job.created_at)) + self.assertEqual("9a373058-af5f-4f83-8662-98b3e0228a73", new_job.id) + self.assertEqual("PublishDatasource", new_job.type) + self.assertEqual("0", new_job.progress) + self.assertEqual("2018-06-30T00:54:54Z", format_datetime(new_job.created_at)) self.assertEqual(1, new_job.finish_code) - def test_publish_unnamed_file_object(self): - new_datasource = TSC.DatasourceItem('test') + def test_publish_unnamed_file_object(self) -> None: + new_datasource = TSC.DatasourceItem("test") publish_mode = self.server.PublishMode.CreateNew - with open(asset('SampleDS.tds'), 'rb') as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, - new_datasource, file_object, publish_mode - ) + with open(asset("SampleDS.tds"), "rb") as file_object: + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, file_object, publish_mode) - def test_refresh_id(self): - self.server.version = '2.8' + def test_refresh_id(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(REFRESH_XML) with requests_mock.mock() as m: - m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', - status_code=202, text=response_xml) - new_job = self.server.datasources.refresh('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) + new_job = self.server.datasources.refresh("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) - self.assertEqual('RefreshExtract', new_job.type) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + self.assertEqual("RefreshExtract", new_job.type) self.assertEqual(None, new_job.progress) - self.assertEqual('2020-03-05T22:05:32Z', format_datetime(new_job.created_at)) + self.assertEqual("2020-03-05T22:05:32Z", format_datetime(new_job.created_at)) self.assertEqual(-1, new_job.finish_code) - def test_refresh_object(self): - self.server.version = '2.8' + def test_refresh_object(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem('') - datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(REFRESH_XML) with requests_mock.mock() as m: - m.post(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh', - status_code=202, text=response_xml) + m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) new_job = self.server.datasources.refresh(datasource) # We only check the `id`; remaining fields are already tested in `test_refresh_id` - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) - def test_update_hyper_data_datasource_object(self): + def test_update_hyper_data_datasource_object(self) -> None: """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem('') - datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(datasource, request_id="test_id", actions=[]) - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) - self.assertEqual('UpdateUploadedFile', new_job.type) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) + self.assertEqual("UpdateUploadedFile", new_job.type) self.assertEqual(None, new_job.progress) - self.assertEqual('2021-09-18T09:40:12Z', format_datetime(new_job.created_at)) + self.assertEqual("2021-09-18T09:40:12Z", format_datetime(new_job.created_at)) self.assertEqual(-1, new_job.finish_code) - def test_update_hyper_data_connection_object(self): + def test_update_hyper_data_connection_object(self) -> None: """Calling `update_hyper_data` with a `ConnectionItem` should update that connection""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl connection = TSC.ConnectionItem() - connection._datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - connection._id = '7ecaccd8-39b0-4875-a77d-094f6e930019' + connection._datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection._id = "7ecaccd8-39b0-4875-a77d-094f6e930019" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(connection, request_id="test_id", actions=[]) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_string(self): + def test_update_hyper_data_datasource_string(self) -> None: """For convenience, calling `update_hyper_data` with a `str` should update the datasource with the corresponding UUID""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) with requests_mock.mock() as m: - m.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data', - status_code=202, headers={"requestid": "test_id"}, text=response_xml) + m.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[]) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_payload_file(self): + def test_update_hyper_data_datasource_payload_file(self) -> None: """If `payload` is present, we upload it and associate the job with it""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' - mock_upload_id = '10051:c3e56879876842d4b3600f20c1f79876-0:0' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + mock_upload_id = "10051:c3e56879876842d4b3600f20c1f79876-0:0" response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as rm, \ - unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): - rm.patch(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=' + mock_upload_id, - status_code=202, headers={"requestid": "test_id"}, text=response_xml) - new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", - actions=[], payload=asset('World Indicators.hyper')) + with requests_mock.mock() as rm, unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): + rm.patch( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=" + mock_upload_id, + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = self.server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload=asset("World Indicators.hyper") + ) # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual('5c0ba560-c959-424e-b08a-f32ef0bfb737', new_job.id) + self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_invalid_payload_file(self): + def test_update_hyper_data_datasource_invalid_payload_file(self) -> None: """If `payload` points to a non-existing file, we report an error""" self.server.version = "3.13" self.baseurl = self.server.datasources.baseurl - datasource_id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" with self.assertRaises(IOError) as cm: - self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", - actions=[], payload='no/such/file.missing') + self.server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload="no/such/file.missing" + ) exception = cm.exception self.assertEqual(str(exception), "File path does not lead to an existing file.") - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', status_code=204) - self.server.datasources.delete('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", status_code=204) + self.server.datasources.delete("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - def test_download(self): + def test_download(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content', - headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}) - file_path = self.server.datasources.download('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb') + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_sanitizes_name(self): + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': disposition}) - file_path = self.server.datasources.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = self.server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertEqual(os.path.basename(file_path), "NameWithCommas.tds") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_extract_only(self): + def test_download_extract_only(self) -> None: # Pretend we're 2.5 for 'extract_only' self.server.version = "2.5" self.baseurl = self.server.datasources.baseurl with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False', - headers={'Content-Disposition': 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - complete_qs=True) - file_path = self.server.datasources.download('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', include_extract=False) + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + complete_qs=True, + ) + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", include_extract=False) self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_update_missing_id(self): - single_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') + def test_update_missing_id(self) -> None: + single_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.datasources.update, single_datasource) - def test_publish_missing_path(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(IOError, self.server.datasources.publish, new_datasource, - '', self.server.PublishMode.CreateNew) - - def test_publish_missing_mode(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - asset('SampleDS.tds'), None) - - def test_publish_invalid_file_type(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - asset('SampleWB.twbx'), self.server.PublishMode.Append) - - def test_publish_hyper_file_object_raises_exception(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - with open(asset('World Indicators.hyper')) as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) - - def test_publish_tde_file_object_raises_exception(self): - - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') - tds_asset = asset(os.path.join('Data', 'Tableau Samples', 'World Indicators.tde')) - with open(tds_asset) as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) - - def test_publish_file_object_of_unknown_type_raises_exception(self): - new_datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'test') + def test_publish_missing_path(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises( + IOError, self.server.datasources.publish, new_datasource, "", self.server.PublishMode.CreateNew + ) + + def test_publish_missing_mode(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset("SampleDS.tds"), None) + + def test_publish_invalid_file_type(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + self.assertRaises( + ValueError, + self.server.datasources.publish, + new_datasource, + asset("SampleWB.twbx"), + self.server.PublishMode.Append, + ) + + def test_publish_hyper_file_object_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with open(asset("World Indicators.hyper"), "rb") as file_object: + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) + + def test_publish_tde_file_object_raises_exception(self) -> None: + + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + tds_asset = asset(os.path.join("Data", "Tableau Samples", "World Indicators.tde")) + with open(tds_asset, "rb") as file_object: + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) + + def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") with BytesIO() as file_object: - file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.write(bytes.fromhex("89504E470D0A1A0A")) file_object.seek(0) - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, - file_object, self.server.PublishMode.Append) + self.assertRaises( + ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append + ) - def test_publish_multi_connection(self): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_publish_multi_connection(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) connection2 = TSC.ConnectionItem() - connection2.server_address = 'pgsql.test.com' - connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) # Can't use ConnectionItem parser due to xml namespace problems - connection_results = ET.fromstring(response).findall('.//connection') + connection_results = fromstring(response).findall(".//connection") - self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') - self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') - self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') - self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") + self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - def test_publish_single_connection(self): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + def test_publish_single_connection(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection_creds = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) # Can't use ConnectionItem parser due to xml namespace problems - credentials = ET.fromstring(response).findall('.//connectionCredentials') + credentials = fromstring(response).findall(".//connectionCredentials") self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get('name', None), 'test') - self.assertEqual(credentials[0].get('password', None), 'secret') - self.assertEqual(credentials[0].get('embed', None), 'true') + self.assertEqual(credentials[0].get("name", None), "test") + self.assertEqual(credentials[0].get("password", None), "secret") + self.assertEqual(credentials[0].get("embed", None), "true") - def test_credentials_and_multi_connect_raises_exception(self): - new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_credentials_and_multi_connect_raises_exception(self) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + connection_creds = TSC.ConnectionCredentials("test", "secret", True) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) with self.assertRaises(RuntimeError): - response = RequestFactory.Datasource._generate_xml(new_datasource, - connection_credentials=connection_creds, - connections=[connection1]) + response = RequestFactory.Datasource._generate_xml( + new_datasource, connection_credentials=connection_creds, connections=[connection1] + ) - def test_synchronous_publish_timeout_error(self): + def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: - m.register_uri('POST', self.baseurl, status_code=504) + m.register_uri("POST", self.baseurl, status_code=504) - new_datasource = TSC.DatasourceItem(project_id='') + new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegex(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, + ) - def test_delete_extracts(self): + def test_delete_extracts(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) - self.server.datasources.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200) + self.server.datasources.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts(self): + def test_create_extracts(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_encrypted(self): + def test_create_extracts_encrypted(self) -> None: self.server.version = "3.10" self.baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.datasources.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', True) + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", True) + + def test_revisions(self) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + + response_xml = read_xml_asset(REVISION_XML) + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + self.server.datasources.populate_revisions(datasource) + revisions = datasource.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + self.server.datasources.delete_revision(datasource.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 600587801..81a26b068 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,5 +1,5 @@ -import datetime import unittest + import tableauserverclient as TSC diff --git a/test/test_exponential_backoff.py b/test/test_exponential_backoff.py index 57229d4ce..a07eb5d3a 100644 --- a/test/test_exponential_backoff.py +++ b/test/test_exponential_backoff.py @@ -1,6 +1,7 @@ import unittest -from ._utils import mocked_time + from tableauserverclient.exponential_backoff import ExponentialBackoffTimer +from ._utils import mocked_time class ExponentialBackoffTests(unittest.TestCase): @@ -21,7 +22,6 @@ def test_exponential(self): exponentialBackoff.sleep() self.assertAlmostEqual(mock_time(), 5.4728) - def test_exponential_saturation(self): with mocked_time() as mock_time: exponentialBackoff = ExponentialBackoffTimer() @@ -36,7 +36,6 @@ def test_exponential_saturation(self): slept = mock_time() - s self.assertAlmostEqual(slept, 30) - def test_timeout(self): with mocked_time() as mock_time: exponentialBackoff = ExponentialBackoffTimer(timeout=4.5) @@ -52,11 +51,10 @@ def test_timeout(self): with self.assertRaises(TimeoutError): exponentialBackoff.sleep() - def test_timeout_zero(self): with mocked_time() as mock_time: # The construction of the timer doesn't throw, yet - exponentialBackoff = ExponentialBackoffTimer(timeout = 0) + exponentialBackoff = ExponentialBackoffTimer(timeout=0) # But the first `sleep` immediately throws with self.assertRaises(TimeoutError): exponentialBackoff.sleep() diff --git a/test/test_favorites.py b/test/test_favorites.py index f76517b64..9dcc3bb38 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -1,129 +1,117 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_FAVORITES_XML = 'favorites_get.xml' -ADD_FAVORITE_WORKBOOK_XML = 'favorites_add_workbook.xml' -ADD_FAVORITE_VIEW_XML = 'favorites_add_view.xml' -ADD_FAVORITE_DATASOURCE_XML = 'favorites_add_datasource.xml' -ADD_FAVORITE_PROJECT_XML = 'favorites_add_project.xml' +GET_FAVORITES_XML = "favorites_get.xml" +ADD_FAVORITE_WORKBOOK_XML = "favorites_add_workbook.xml" +ADD_FAVORITE_VIEW_XML = "favorites_add_view.xml" +ADD_FAVORITE_DATASOURCE_XML = "favorites_add_datasource.xml" +ADD_FAVORITE_PROJECT_XML = "favorites_add_project.xml" class FavoritesTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '2.5' + self.server = TSC.Server("http://test", False) + self.server.version = "2.5" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.favorites.baseurl - self.user = TSC.UserItem('alice', TSC.UserItem.Roles.Viewer) - self.user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + self.user = TSC.UserItem("alice", TSC.UserItem.Roles.Viewer) + self.user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) - self.assertEqual(len(self.user.favorites['workbooks']), 1) - self.assertEqual(len(self.user.favorites['views']), 1) - self.assertEqual(len(self.user.favorites['projects']), 1) - self.assertEqual(len(self.user.favorites['datasources']), 1) - - workbook = self.user.favorites['workbooks'][0] - view = self.user.favorites['views'][0] - datasource = self.user.favorites['datasources'][0] - project = self.user.favorites['projects'][0] - - self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') - self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') - self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') - self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') - - def test_add_favorite_workbook(self): + self.assertEqual(len(self.user.favorites["workbooks"]), 1) + self.assertEqual(len(self.user.favorites["views"]), 1) + self.assertEqual(len(self.user.favorites["projects"]), 1) + self.assertEqual(len(self.user.favorites["datasources"]), 1) + + workbook = self.user.favorites["workbooks"][0] + view = self.user.favorites["views"][0] + datasource = self.user.favorites["datasources"][0] + project = self.user.favorites["projects"][0] + + self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") + self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") + self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") + self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + + def test_add_favorite_workbook(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) - workbook = TSC.WorkbookItem('') - workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' - workbook.name = 'Superstore' + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) - def test_add_favorite_view(self): + def test_add_favorite_view(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) view = TSC.ViewItem() - view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - view._name = 'ENDANGERED SAFARI' + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_view(self.user, view) - def test_add_favorite_datasource(self): + def test_add_favorite_datasource(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) - datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' - datasource.name = 'SampleDS' + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(self.baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) - def test_add_favorite_project(self): - self.server.version = '3.1' + def test_add_favorite_project(self) -> None: + self.server.version = "3.1" baseurl = self.server.favorites.baseurl response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) - project = TSC.ProjectItem('Tableau') - project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put('{0}/{1}'.format(baseurl, self.user.id), - text=response_xml) + m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_project(self.user, project) - def test_delete_favorite_workbook(self): - workbook = TSC.WorkbookItem('') - workbook._id = '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00' - workbook.name = 'Superstore' + def test_delete_favorite_workbook(self) -> None: + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete('{0}/{1}/workbooks/{2}'.format(self.baseurl, self.user.id, - workbook.id)) + m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) self.server.favorites.delete_favorite_workbook(self.user, workbook) - def test_delete_favorite_view(self): + def test_delete_favorite_view(self) -> None: view = TSC.ViewItem() - view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - view._name = 'ENDANGERED SAFARI' + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete('{0}/{1}/views/{2}'.format(self.baseurl, self.user.id, - view.id)) + m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) self.server.favorites.delete_favorite_view(self.user, view) - def test_delete_favorite_datasource(self): - datasource = TSC.DatasourceItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - datasource._id = 'e76a1461-3b1d-4588-bf1b-17551a879ad9' - datasource.name = 'SampleDS' + def test_delete_favorite_datasource(self) -> None: + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete('{0}/{1}/datasources/{2}'.format(self.baseurl, self.user.id, - datasource.id)) + m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) self.server.favorites.delete_favorite_datasource(self.user, datasource) - def test_delete_favorite_project(self): - self.server.version = '3.1' + def test_delete_favorite_project(self) -> None: + self.server.version = "3.1" baseurl = self.server.favorites.baseurl - project = TSC.ProjectItem('Tableau') - project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete('{0}/{1}/projects/{2}'.format(baseurl, self.user.id, - project.id)) + m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 82fce8476..645c5d372 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -1,6 +1,6 @@ +import os import unittest from io import BytesIO -import os from xml.etree import ElementTree as ET from zipfile import ZipFile @@ -9,7 +9,6 @@ class FilesysTests(unittest.TestCase): - def test_get_file_size_returns_correct_size(self): target_size = 1000 # bytes @@ -30,9 +29,9 @@ def test_get_file_size_returns_zero_for_empty_file(self): def test_get_file_size_coincides_with_built_in_method(self): - asset_path = asset('SampleWB.twbx') + asset_path = asset("SampleWB.twbx") target_size = os.path.getsize(asset_path) - with open(asset_path, 'rb') as f: + with open(asset_path, "rb") as f: file_size = get_file_object_size(f) self.assertEqual(file_size, target_size) @@ -40,61 +39,60 @@ def test_get_file_size_coincides_with_built_in_method(self): def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: - with ZipFile(file_object, 'w') as zf: + with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write('This is a zip file'.encode()) - zf.writestr('dummy_file', stream.getbuffer()) + stream.write("This is a zip file".encode()) + zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_tdsx_as_zip_file(self): - with open(asset('World Indicators.tdsx'), 'rb') as file_object: + with open(asset("World Indicators.tdsx"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_twbx_as_zip_file(self): - with open(asset('SampleWB.twbx'), 'rb') as file_object: + with open(asset("SampleWB.twbx"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'zip') + self.assertEqual(file_type, "zip") def test_get_file_type_identifies_xml_file(self): - root = ET.Element('root') - child = ET.SubElement(root, 'child') + root = ET.Element("root") + child = ET.SubElement(root, "child") child.text = "This is a child element" etree = ET.ElementTree(root) with BytesIO() as file_object: - etree.write(file_object, encoding='utf-8', - xml_declaration=True) + etree.write(file_object, encoding="utf-8", xml_declaration=True) file_object.seek(0) file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_tds_as_xml_file(self): - with open(asset('World Indicators.tds'), 'rb') as file_object: + with open(asset("World Indicators.tds"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_twb_as_xml_file(self): - with open(asset('RESTAPISample.twb'), 'rb') as file_object: + with open(asset("RESTAPISample.twb"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'xml') + self.assertEqual(file_type, "xml") def test_get_file_type_identifies_hyper_file(self): - with open(asset('World Indicators.hyper'), 'rb') as file_object: + with open(asset("World Indicators.hyper"), "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'hyper') + self.assertEqual(file_type, "hyper") def test_get_file_type_identifies_tde_file(self): - asset_path = os.path.join(TEST_ASSET_DIR, 'Data', 'Tableau Samples', 'World Indicators.tde') - with open(asset_path, 'rb') as file_object: + asset_path = os.path.join(TEST_ASSET_DIR, "Data", "Tableau Samples", "World Indicators.tde") + with open(asset_path, "rb") as file_object: file_type = get_file_type(file_object) - self.assertEqual(file_type, 'tde') + self.assertEqual(file_type, "tde") def test_get_file_type_handles_unknown_file_type(self): diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 51662e4a2..4d3b0c864 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,63 +1,64 @@ import os -import requests_mock import unittest -from ._utils import asset +import requests_mock + from tableauserverclient.server import Server +from ._utils import asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') -FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, 'fileupload_initialize.xml') -FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, 'fileupload_append.xml') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, "fileupload_initialize.xml") +FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") class FileuploadsTests(unittest.TestCase): def setUp(self): - self.server = Server('http://test') + self.server = Server("http://test", False) # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = '{}/sites/{}/fileUploads'.format(self.server.baseurl, self.server.site_id) + self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): - file_path = asset('SampleWB.twbx') + file_path = asset("SampleWB.twbx") chunks = self.server.fileuploads._read_chunks(file_path) for chunk in chunks: self.assertIsNotNone(chunk) def test_read_chunks_file_object(self): - with open(asset('SampleWB.twbx'), 'rb') as f: + with open(asset("SampleWB.twbx"), "rb") as f: chunks = self.server.fileuploads._read_chunks(f) for chunk in chunks: self.assertIsNotNone(chunk) def test_upload_chunks_file_path(self): - file_path = asset('SampleWB.twbx') - upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + file_path = asset("SampleWB.twbx") + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - with open(FILEUPLOAD_INITIALIZE, 'rb') as f: - initialize_response_xml = f.read().decode('utf-8') - with open(FILEUPLOAD_APPEND, 'rb') as f: - append_response_xml = f.read().decode('utf-8') + with open(FILEUPLOAD_INITIALIZE, "rb") as f: + initialize_response_xml = f.read().decode("utf-8") + with open(FILEUPLOAD_APPEND, "rb") as f: + append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + m.put(self.baseurl + "/" + upload_id, text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) def test_upload_chunks_file_object(self): - upload_id = '7720:170fe6b1c1c7422dadff20f944d58a52-1:0' + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - with open(asset('SampleWB.twbx'), 'rb') as file_content: - with open(FILEUPLOAD_INITIALIZE, 'rb') as f: - initialize_response_xml = f.read().decode('utf-8') - with open(FILEUPLOAD_APPEND, 'rb') as f: - append_response_xml = f.read().decode('utf-8') + with open(asset("SampleWB.twbx"), "rb") as file_content: + with open(FILEUPLOAD_INITIALIZE, "rb") as f: + initialize_response_xml = f.read().decode("utf-8") + with open(FILEUPLOAD_APPEND, "rb") as f: + append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + '/' + upload_id, text=append_response_xml) + m.put(self.baseurl + "/" + upload_id, text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flow.py b/test/test_flow.py index 545623d03..269bc2f7e 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,135 +1,135 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset, asset -GET_XML = 'flow_get.xml' -POPULATE_CONNECTIONS_XML = 'flow_populate_connections.xml' -POPULATE_PERMISSIONS_XML = 'flow_populate_permissions.xml' -UPDATE_XML = 'flow_update.xml' -REFRESH_XML = 'flow_refresh.xml' +GET_XML = "flow_get.xml" +POPULATE_CONNECTIONS_XML = "flow_populate_connections.xml" +POPULATE_PERMISSIONS_XML = "flow_populate_permissions.xml" +UPDATE_XML = "flow_update.xml" +REFRESH_XML = "flow_refresh.xml" class FlowTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.flows.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_flows, pagination_item = self.server.flows.get() self.assertEqual(5, pagination_item.total_available) - self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', all_flows[0].id) - self.assertEqual('http://tableauserver/#/flows/1', all_flows[0].webpage_url) - self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].created_at)) - self.assertEqual('2019-06-16T21:43:28Z', format_datetime(all_flows[0].updated_at)) - self.assertEqual('Default', all_flows[0].project_name) - self.assertEqual('FlowOne', all_flows[0].name) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[0].project_id) - self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', all_flows[0].owner_id) - self.assertEqual({'i_love_tags'}, all_flows[0].tags) - self.assertEqual('Descriptive', all_flows[0].description) - - self.assertEqual('5c36be69-eb30-461b-b66e-3e2a8e27cc35', all_flows[1].id) - self.assertEqual('http://tableauserver/#/flows/4', all_flows[1].webpage_url) - self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].created_at)) - self.assertEqual('2019-06-18T03:08:19Z', format_datetime(all_flows[1].updated_at)) - self.assertEqual('Default', all_flows[1].project_name) - self.assertEqual('FlowTwo', all_flows[1].name) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flows[1].project_id) - self.assertEqual('9127d03f-d996-405f-b392-631b25183a0f', all_flows[1].owner_id) - - def test_update(self): + self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", all_flows[0].id) + self.assertEqual("http://tableauserver/#/flows/1", all_flows[0].webpage_url) + self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].created_at)) + self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].updated_at)) + self.assertEqual("Default", all_flows[0].project_name) + self.assertEqual("FlowOne", all_flows[0].name) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[0].project_id) + self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", all_flows[0].owner_id) + self.assertEqual({"i_love_tags"}, all_flows[0].tags) + self.assertEqual("Descriptive", all_flows[0].description) + + self.assertEqual("5c36be69-eb30-461b-b66e-3e2a8e27cc35", all_flows[1].id) + self.assertEqual("http://tableauserver/#/flows/4", all_flows[1].webpage_url) + self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].created_at)) + self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].updated_at)) + self.assertEqual("Default", all_flows[1].project_name) + self.assertEqual("FlowTwo", all_flows[1].name) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[1].project_id) + self.assertEqual("9127d03f-d996-405f-b392-631b25183a0f", all_flows[1].owner_id) + + def test_update(self) -> None: response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/587daa37-b84d-4400-a9a2-aa90e0be7837', text=response_xml) - single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') - single_datasource.owner_id = '7ebb3f20-0fd2-4f27-a2f6-c539470999e2' - single_datasource._id = '587daa37-b84d-4400-a9a2-aa90e0be7837' + m.put(self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" + single_datasource._id = "587daa37-b84d-4400-a9a2-aa90e0be7837" single_datasource.description = "So fun to see" single_datasource = self.server.flows.update(single_datasource) - self.assertEqual('587daa37-b84d-4400-a9a2-aa90e0be7837', single_datasource.id) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', single_datasource.project_id) - self.assertEqual('7ebb3f20-0fd2-4f27-a2f6-c539470999e2', single_datasource.owner_id) + self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", single_datasource.id) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", single_datasource.project_id) + self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", single_datasource.owner_id) self.assertEqual("So fun to see", single_datasource.description) - def test_populate_connections(self): + def test_populate_connections(self) -> None: response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections', text=response_xml) - single_datasource = TSC.FlowItem('test', 'aa23f4ac-906f-11e9-86fb-3f0f71412e77') - single_datasource.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_datasource._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.flows.populate_connections(single_datasource) - self.assertEqual('9dbd2263-16b5-46e1-9c43-a76bb8ab65fb', single_datasource.id) + self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) connections = single_datasource.connections self.assertTrue(connections) conn1, conn2, conn3 = connections - self.assertEqual('405c1e4b-60c9-499f-9c47-a4ef1af69359', conn1.id) - self.assertEqual('excel-direct', conn1.connection_type) - self.assertEqual('', conn1.server_address) - self.assertEqual('', conn1.username) + self.assertEqual("405c1e4b-60c9-499f-9c47-a4ef1af69359", conn1.id) + self.assertEqual("excel-direct", conn1.connection_type) + self.assertEqual("", conn1.server_address) + self.assertEqual("", conn1.username) self.assertEqual(False, conn1.embed_password) - self.assertEqual('b47f41b1-2c47-41a3-8b17-a38ebe8b340c', conn2.id) - self.assertEqual('sqlserver', conn2.connection_type) - self.assertEqual('test.database.com', conn2.server_address) - self.assertEqual('bob', conn2.username) + self.assertEqual("b47f41b1-2c47-41a3-8b17-a38ebe8b340c", conn2.id) + self.assertEqual("sqlserver", conn2.connection_type) + self.assertEqual("test.database.com", conn2.server_address) + self.assertEqual("bob", conn2.username) self.assertEqual(False, conn2.embed_password) - self.assertEqual('4f4a3b78-0554-43a7-b327-9605e9df9dd2', conn3.id) - self.assertEqual('tableau-server-site', conn3.connection_type) - self.assertEqual('http://tableauserver', conn3.server_address) - self.assertEqual('sally', conn3.username) + self.assertEqual("4f4a3b78-0554-43a7-b327-9605e9df9dd2", conn3.id) + self.assertEqual("tableau-server-site", conn3.connection_type) + self.assertEqual("http://tableauserver", conn3.server_address) + self.assertEqual("sally", conn3.username) self.assertEqual(True, conn3.embed_password) - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_datasource = TSC.FlowItem('test') - single_datasource._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.FlowItem("test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.flows.populate_permissions(single_datasource) permissions = single_datasource.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'aa42f384-906f-11e9-86fc-bb24278874b9') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "aa42f384-906f-11e9-86fc-bb24278874b9") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) def test_refresh(self): - with open(asset(REFRESH_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(asset(REFRESH_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/92967d2d-c7e2-46d0-8847-4802df58f484/run', text=response_xml) - flow_item = TSC.FlowItem('test') - flow_item._id = '92967d2d-c7e2-46d0-8847-4802df58f484' + m.post(self.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) + flow_item = TSC.FlowItem("test") + flow_item._id = "92967d2d-c7e2-46d0-8847-4802df58f484" refresh_job = self.server.flows.refresh(flow_item) - self.assertEqual(refresh_job.id, 'd1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d') - self.assertEqual(refresh_job.mode, 'Asynchronous') - self.assertEqual(refresh_job.type, 'RunFlow') - self.assertEqual(format_datetime(refresh_job.created_at), '2018-05-22T13:00:29Z') + self.assertEqual(refresh_job.id, "d1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d") + self.assertEqual(refresh_job.mode, "Asynchronous") + self.assertEqual(refresh_job.type, "RunFlow") + self.assertEqual(format_datetime(refresh_job.created_at), "2018-05-22T13:00:29Z") self.assertIsInstance(refresh_job.flow_run, TSC.FlowRunItem) - self.assertEqual(refresh_job.flow_run.id, 'e0c3067f-2333-4eee-8028-e0a56ca496f6') - self.assertEqual(refresh_job.flow_run.flow_id, '92967d2d-c7e2-46d0-8847-4802df58f484') - self.assertEqual(format_datetime(refresh_job.flow_run.started_at), '2018-05-22T13:00:29Z') - + self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6") + self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") + self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") diff --git a/test/test_flowruns.py b/test/test_flowruns.py index d2e72f31a..864c0d3cd 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,104 +1,100 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from tableauserverclient.server.request_factory import RequestFactory from ._utils import read_xml_asset, mocked_time -GET_XML = 'flow_runs_get.xml' -GET_BY_ID_XML = 'flow_runs_get_by_id.xml' -GET_BY_ID_FAILED_XML = 'flow_runs_get_by_id_failed.xml' -GET_BY_ID_INPROGRESS_XML = 'flow_runs_get_by_id_inprogress.xml' +GET_XML = "flow_runs_get.xml" +GET_BY_ID_XML = "flow_runs_get_by_id.xml" +GET_BY_ID_FAILED_XML = "flow_runs_get_by_id_failed.xml" +GET_BY_ID_INPROGRESS_XML = "flow_runs_get_by_id_inprogress.xml" class FlowRunTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.10" self.baseurl = self.server.flow_runs.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_flow_runs, pagination_item = self.server.flow_runs.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', all_flow_runs[0].id) - self.assertEqual('2021-02-11T01:42:55Z', format_datetime(all_flow_runs[0].started_at)) - self.assertEqual('2021-02-11T01:57:38Z', format_datetime(all_flow_runs[0].completed_at)) - self.assertEqual('Success', all_flow_runs[0].status) - self.assertEqual('100', all_flow_runs[0].progress) - self.assertEqual('aa23f4ac-906f-11e9-86fb-3f0f71412e77', all_flow_runs[0].background_job_id) - - self.assertEqual('a3104526-c0c6-4ea5-8362-e03fc7cbd7ee', all_flow_runs[1].id) - self.assertEqual('2021-02-13T04:05:30Z', format_datetime(all_flow_runs[1].started_at)) - self.assertEqual('2021-02-13T04:05:35Z', format_datetime(all_flow_runs[1].completed_at)) - self.assertEqual('Failed', all_flow_runs[1].status) - self.assertEqual('100', all_flow_runs[1].progress) - self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', all_flow_runs[1].background_job_id) - - def test_get_by_id(self): + self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) + self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) + self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) + self.assertEqual("Success", all_flow_runs[0].status) + self.assertEqual("100", all_flow_runs[0].progress) + self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flow_runs[0].background_job_id) + + self.assertEqual("a3104526-c0c6-4ea5-8362-e03fc7cbd7ee", all_flow_runs[1].id) + self.assertEqual("2021-02-13T04:05:30Z", format_datetime(all_flow_runs[1].started_at)) + self.assertEqual("2021-02-13T04:05:35Z", format_datetime(all_flow_runs[1].completed_at)) + self.assertEqual("Failed", all_flow_runs[1].status) + self.assertEqual("100", all_flow_runs[1].progress) + self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", all_flow_runs[1].background_job_id) + + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) with requests_mock.mock() as m: m.get(self.baseurl + "/cc2e652d-4a9b-4476-8c93-b238c45db968", text=response_xml) flow_run = self.server.flow_runs.get_by_id("cc2e652d-4a9b-4476-8c93-b238c45db968") - - self.assertEqual('cc2e652d-4a9b-4476-8c93-b238c45db968', flow_run.id) - self.assertEqual('2021-02-11T01:42:55Z', format_datetime(flow_run.started_at)) - self.assertEqual('2021-02-11T01:57:38Z', format_datetime(flow_run.completed_at)) - self.assertEqual('Success', flow_run.status) - self.assertEqual('100', flow_run.progress) - self.assertEqual('1ad21a9d-2530-4fbf-9064-efd3c736e023', flow_run.background_job_id) - - def test_cancel_id(self): + + self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", flow_run.id) + self.assertEqual("2021-02-11T01:42:55Z", format_datetime(flow_run.started_at)) + self.assertEqual("2021-02-11T01:57:38Z", format_datetime(flow_run.completed_at)) + self.assertEqual("Success", flow_run.status) + self.assertEqual("100", flow_run.progress) + self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", flow_run.background_job_id) + + def test_cancel_id(self) -> None: with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.flow_runs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.flow_runs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_cancel_item(self): + def test_cancel_item(self) -> None: run = TSC.FlowRunItem() - run._id = 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760' + run._id = "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) self.server.flow_runs.cancel(run) - - def test_wait_for_job_finished(self): + def test_wait_for_job_finished(self) -> None: # Waiting for an already finished job, directly returns that job's info response_xml = read_xml_asset(GET_BY_ID_XML) - flow_run_id = 'cc2e652d-4a9b-4476-8c93-b238c45db968' + flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) self.assertEqual(flow_run.progress, "100") - - def test_wait_for_job_failed(self): + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - flow_run_id = 'c2b35d5a-e130-471a-aec8-7bc5435fe0e7' + flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) - - def test_wait_for_job_timeout(self): + def test_wait_for_job_timeout(self) -> None: # Waiting for a job which doesn't terminate will throw an exception response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - flow_run_id = '71afc22c-9c06-40be-8d0f-4c4166d29e6c' + flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, flow_run_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) diff --git a/test/test_group.py b/test/test_group.py index 082a63ba3..d948090ca 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,234 +1,249 @@ # encoding=utf-8 -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = os.path.join(TEST_ASSET_DIR, 'group_get.xml') -POPULATE_USERS = os.path.join(TEST_ASSET_DIR, 'group_populate_users.xml') -POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, 'group_populate_users_empty.xml') -ADD_USER = os.path.join(TEST_ASSET_DIR, 'group_add_user.xml') -ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, 'group_users_added.xml') -CREATE_GROUP = os.path.join(TEST_ASSET_DIR, 'group_create.xml') -CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, 'group_create_ad.xml') -CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, 'group_create_async.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'group_update.xml') +GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") +POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") +ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") +CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") +CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") +CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") class GroupTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.groups.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + 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) all_groups, pagination_item = self.server.groups.get() self.assertEqual(3, pagination_item.total_available) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', all_groups[0].id) - self.assertEqual('All Users', all_groups[0].name) - self.assertEqual('local', all_groups[0].domain_name) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", all_groups[0].id) + self.assertEqual("All Users", all_groups[0].name) + self.assertEqual("local", all_groups[0].domain_name) - self.assertEqual('e7833b48-c6f7-47b5-a2a7-36e7dd232758', all_groups[1].id) - self.assertEqual('Another group', all_groups[1].name) - self.assertEqual('local', all_groups[1].domain_name) + self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", all_groups[1].id) + self.assertEqual("Another group", all_groups[1].name) + self.assertEqual("local", all_groups[1].domain_name) - self.assertEqual('86a66d40-f289-472a-83d0-927b0f954dc8', all_groups[2].id) - self.assertEqual('TableauExample', all_groups[2].name) - self.assertEqual('local', all_groups[2].domain_name) + self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", all_groups[2].id) + self.assertEqual("TableauExample", all_groups[2].name) + self.assertEqual("local", all_groups[2].domain_name) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.groups.get) - def test_populate_users(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_users(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100', - text=response_xml, complete_qs=True) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get( + self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100", + text=response_xml, + complete_qs=True, + ) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) user = list(single_group.users).pop() - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', user.id) - self.assertEqual('alice', user.name) - self.assertEqual('Publisher', user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(user.last_login)) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", user.id) + self.assertEqual("alice", user.name) + self.assertEqual("Publisher", user.site_role) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(user.last_login)) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758', status_code=204) - self.server.groups.delete('e7833b48-c6f7-47b5-a2a7-36e7dd232758') + m.delete(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758", status_code=204) + self.server.groups.delete("e7833b48-c6f7-47b5-a2a7-36e7dd232758") - def test_remove_user(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml_populate = f.read().decode('utf-8') + def test_remove_user(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml_populate = f.read().decode("utf-8") - with open(POPULATE_USERS_EMPTY, 'rb') as f: - response_xml_empty = f.read().decode('utf-8') + with open(POPULATE_USERS_EMPTY, "rb") as f: + response_xml_empty = f.read().decode("utf-8") with requests_mock.mock() as m: - url = self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users' \ - '/dd2239f6-ddf1-4107-981a-4cf94e415794' + url = self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users" "/dd2239f6-ddf1-4107-981a-4cf94e415794" m.delete(url, status_code=204) # We register the get endpoint twice. The first time we have 1 user, the second we have 'removed' them. - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_populate) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) - single_group = TSC.GroupItem('test') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + single_group = TSC.GroupItem("test") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) - self.server.groups.remove_user(single_group, 'dd2239f6-ddf1-4107-981a-4cf94e415794') + self.server.groups.remove_user(single_group, "dd2239f6-ddf1-4107-981a-4cf94e415794") - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_empty) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_empty) self.assertEqual(0, len(list(single_group.users))) - def test_add_user(self): - with open(ADD_USER, 'rb') as f: - response_xml_add = f.read().decode('utf-8') - with open(ADD_USER_POPULATE, 'rb') as f: - response_xml_populate = f.read().decode('utf-8') + def test_add_user(self) -> None: + with open(ADD_USER, "rb") as f: + response_xml_add = f.read().decode("utf-8") + with open(ADD_USER_POPULATE, "rb") as f: + response_xml_populate = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_add) - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml_populate) - single_group = TSC.GroupItem('test') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.post(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_add) + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) + single_group = TSC.GroupItem("test") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.add_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") self.server.groups.populate_users(single_group) self.assertEqual(1, len(list(single_group.users))) user = list(single_group.users).pop() - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', user.id) - self.assertEqual('testuser', user.name) - self.assertEqual('ServerAdministrator', user.site_role) - - def test_add_user_before_populating(self): - with open(GET_XML, 'rb') as f: - get_xml_response = f.read().decode('utf-8') - with open(ADD_USER, 'rb') as f: - add_user_response = f.read().decode('utf-8') + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", user.id) + self.assertEqual("testuser", user.name) + self.assertEqual("ServerAdministrator", user.site_role) + + def test_add_user_before_populating(self) -> None: + with open(GET_XML, "rb") as f: + get_xml_response = f.read().decode("utf-8") + with open(ADD_USER, "rb") as f: + add_user_response = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) - m.post('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' - '-63f5805dbe3c/users', text=add_user_response) + m.post( + "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" + "-63f5805dbe3c/users", + text=add_user_response, + ) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] - self.server.groups.add_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - def test_add_user_missing_user_id(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_add_user_missing_user_id(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) - self.assertRaises(ValueError, self.server.groups.add_user, single_group, '') + self.assertRaises(ValueError, self.server.groups.add_user, single_group, "") - def test_add_user_missing_group_id(self): - single_group = TSC.GroupItem('test') - single_group._users = [] - self.assertRaises(TSC.MissingRequiredFieldError, self.server.groups.add_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + def test_add_user_missing_group_id(self) -> None: + single_group = TSC.GroupItem("test") + self.assertRaises( + TSC.MissingRequiredFieldError, + self.server.groups.add_user, + single_group, + "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + ) - def test_remove_user_before_populating(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_remove_user_before_populating(self) -> None: + 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) - m.delete('http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50' - '-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', - text='ok') + m.delete( + "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" + "-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + text="ok", + ) all_groups, pagination_item = self.server.groups.get() single_group = all_groups[0] - self.server.groups.remove_user(single_group, '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + self.server.groups.remove_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - def test_remove_user_missing_user_id(self): - with open(POPULATE_USERS, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_remove_user_missing_user_id(self) -> None: + with open(POPULATE_USERS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users', text=response_xml) - single_group = TSC.GroupItem(name='Test Group') - single_group._id = 'e7833b48-c6f7-47b5-a2a7-36e7dd232758' + m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" self.server.groups.populate_users(single_group) - self.assertRaises(ValueError, self.server.groups.remove_user, single_group, '') + self.assertRaises(ValueError, self.server.groups.remove_user, single_group, "") - def test_remove_user_missing_group_id(self): - single_group = TSC.GroupItem('test') - single_group._users = [] - self.assertRaises(TSC.MissingRequiredFieldError, self.server.groups.remove_user, single_group, - '5de011f8-5aa9-4d5b-b991-f462c8dd6bb7') + def test_remove_user_missing_group_id(self) -> None: + single_group = TSC.GroupItem("test") + self.assertRaises( + TSC.MissingRequiredFieldError, + self.server.groups.remove_user, + single_group, + "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + ) - def test_create_group(self): - with open(CREATE_GROUP, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create_group(self) -> None: + with open(CREATE_GROUP, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') + group_to_create = TSC.GroupItem("試供品") group = self.server.groups.create(group_to_create) - self.assertEqual(group.name, u'試供品') - self.assertEqual(group.id, '3e4a9ea0-a07a-4fe6-b50f-c345c8c81034') + self.assertEqual(group.name, "試供品") + self.assertEqual(group.id, "3e4a9ea0-a07a-4fe6-b50f-c345c8c81034") - def test_create_ad_group(self): - with open(CREATE_GROUP_AD, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create_ad_group(self) -> None: + with open(CREATE_GROUP_AD, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') - group_to_create.domain_name = 'just-has-to-exist' + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "just-has-to-exist" group = self.server.groups.create_AD_group(group_to_create, False) - self.assertEqual(group.name, u'試供品') - self.assertEqual(group.license_mode, 'onLogin') - self.assertEqual(group.minimum_site_role, 'Creator') - self.assertEqual(group.domain_name, 'active-directory-domain-name') - - def test_create_group_async(self): - with open(CREATE_GROUP_ASYNC, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(group.name, "試供品") + self.assertEqual(group.license_mode, "onLogin") + self.assertEqual(group.minimum_site_role, "Creator") + self.assertEqual(group.domain_name, "active-directory-domain-name") + + def test_create_group_async(self) -> None: + with open(CREATE_GROUP_ASYNC, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem(u'試供品') - group_to_create.domain_name = 'woohoo' + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "woohoo" job = self.server.groups.create_AD_group(group_to_create, True) - self.assertEqual(job.mode, 'Asynchronous') - self.assertEqual(job.type, 'GroupImport') + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupImport") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/ef8b19c0-43b6-11e6-af50-63f5805dbe3c', text=response_xml) - group = TSC.GroupItem(name='Test Group') - group._domain_name = 'local' - group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + m.put(self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c", text=response_xml) + group = TSC.GroupItem(name="Test Group") + group._domain_name = "local" + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" group = self.server.groups.update(group) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group.id) - self.assertEqual('Group updated name', group.name) - self.assertEqual('ExplorerCanPublish', group.minimum_site_role) - self.assertEqual('onLogin', group.license_mode) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group.id) + self.assertEqual("Group updated name", group.name) + self.assertEqual("ExplorerCanPublish", group.minimum_site_role) + self.assertEqual("onLogin", group.license_mode) # async update is not supported for local groups - def test_update_local_async(self): + def test_update_local_async(self) -> None: group = TSC.GroupItem("myGroup") - group._id = 'ef8b19c0-43b6-11e6-af50-63f5805dbe3c' + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) # mimic group returned from server where domain name is set to 'local' diff --git a/test/test_group_model.py b/test/test_group_model.py index 617a5d954..6b79dc18a 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_job.py b/test/test_job.py index 70bca996c..6daa16afa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -1,33 +1,35 @@ -import unittest import os +import unittest from datetime import datetime + import requests_mock + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc from tableauserverclient.server.endpoint.exceptions import JobFailedException from ._utils import read_xml_asset, mocked_time -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = 'job_get.xml' -GET_BY_ID_XML = 'job_get_by_id.xml' -GET_BY_ID_FAILED_XML = 'job_get_by_id_failed.xml' -GET_BY_ID_CANCELLED_XML = 'job_get_by_id_cancelled.xml' -GET_BY_ID_INPROGRESS_XML = 'job_get_by_id_inprogress.xml' +GET_XML = "job_get.xml" +GET_BY_ID_XML = "job_get_by_id.xml" +GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" +GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" +GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" class JobTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '3.1' + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.1" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.jobs.baseurl - def test_get(self): + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) @@ -38,70 +40,66 @@ def test_get(self): ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) 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("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_by_id(self): + def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ['Job detail notes']) + self.assertListEqual(job.notes, ["Job detail notes"]) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) - def test_cancel_id(self): + def test_cancel_id(self) -> None: with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.jobs.cancel('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.jobs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_cancel_item(self): + def test_cancel_item(self) -> None: created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) - job = TSC.JobItem('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', 'backgroundJob', - 0, created_at, started_at, None, 0) + job = TSC.JobItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "backgroundJob", "0", created_at, started_at, None, 0) with requests_mock.mock() as m: - m.put(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) self.server.jobs.cancel(job) - - def test_wait_for_job_finished(self): + def test_wait_for_job_finished(self) -> None: # Waiting for an already finished job, directly returns that job's info response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = '2eef4225-aa0c-41c4-8662-a76d89ed7336' + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ['Job detail notes']) - + self.assertListEqual(job.notes, ["Job detail notes"]) - def test_wait_for_job_failed(self): + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) - - def test_wait_for_job_timeout(self): + def test_wait_for_job_timeout(self) -> None: # Waiting for a job which doesn't terminate will throw an exception response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - job_id = '77d5e57a-2517-479f-9a3c-a32025f2b64d' + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get('{0}/{1}'.format(self.baseurl, job_id), text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_metadata.py b/test/test_metadata.py index 1c0846d73..1dc9cf1c6 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,98 +1,102 @@ -import unittest -import os.path import json +import os.path +import unittest + import requests_mock -import tableauserverclient as TSC +import tableauserverclient as TSC from tableauserverclient.server.endpoint.exceptions import GraphQLError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, 'metadata_query_success.json') -METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, 'metadata_query_error.json') -EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, 'metadata_query_expected_dict.dict') +METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, "metadata_query_success.json") +METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, "metadata_query_error.json") +EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, "metadata_query_expected_dict.dict") -METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_1.json') -METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_2.json') -METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, 'metadata_paged_3.json') +METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, "metadata_paged_1.json") +METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, "metadata_paged_2.json") +METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, "metadata_paged_3.json") -EXPECTED_DICT = {'publishedDatasources': - [{'id': '01cf92b2-2d17-b656-fc48-5c25ef6d5352', 'name': 'Batters (TestV1)'}, - {'id': '020ae1cd-c356-f1ad-a846-b0094850d22a', 'name': 'SharePoint_List_sharepoint2010.test.tsi.lan'}, - {'id': '061493a0-c3b2-6f39-d08c-bc3f842b44af', 'name': 'Batters_mongodb'}, - {'id': '089fe515-ad2f-89bc-94bd-69f55f69a9c2', 'name': 'Sample - Superstore'}]} +EXPECTED_DICT = { + "publishedDatasources": [ + {"id": "01cf92b2-2d17-b656-fc48-5c25ef6d5352", "name": "Batters (TestV1)"}, + {"id": "020ae1cd-c356-f1ad-a846-b0094850d22a", "name": "SharePoint_List_sharepoint2010.test.tsi.lan"}, + {"id": "061493a0-c3b2-6f39-d08c-bc3f842b44af", "name": "Batters_mongodb"}, + {"id": "089fe515-ad2f-89bc-94bd-69f55f69a9c2", "name": "Sample - Superstore"}, + ] +} -EXPECTED_DICT_ERROR = [ - { - "message": "Reached time limit of PT5S for query execution.", - "path": None, - "extensions": None - } -] +EXPECTED_DICT_ERROR = [{"message": "Reached time limit of PT5S for query execution.", "path": None, "extensions": None}] class MetadataTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.metadata.baseurl self.server.version = "3.5" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" def test_metadata_query(self): - with open(METADATA_QUERY_SUCCESS, 'rb') as f: + with open(METADATA_QUERY_SUCCESS, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query('fake query') + actual = self.server.metadata.query("fake query") - datasources = actual['data'] + datasources = actual["data"] self.assertDictEqual(EXPECTED_DICT, datasources) def test_paged_metadata_query(self): - with open(EXPECTED_PAGED_DICT, 'rb') as f: + with open(EXPECTED_PAGED_DICT, "rb") as f: expected = eval(f.read()) # prepare the 3 pages of results - with open(METADATA_PAGE_1, 'rb') as f: + with open(METADATA_PAGE_1, "rb") as f: result_1 = f.read().decode() - with open(METADATA_PAGE_2, 'rb') as f: + with open(METADATA_PAGE_2, "rb") as f: result_2 = f.read().decode() - with open(METADATA_PAGE_3, 'rb') as f: + with open(METADATA_PAGE_3, "rb") as f: result_3 = f.read().decode() with requests_mock.mock() as m: - m.post(self.baseurl, [{'text': result_1, 'status_code': 200}, - {'text': result_2, 'status_code': 200}, - {'text': result_3, 'status_code': 200}]) + m.post( + self.baseurl, + [ + {"text": result_1, "status_code": 200}, + {"text": result_2, "status_code": 200}, + {"text": result_3, "status_code": 200}, + ], + ) # validation checks for endCursor and hasNextPage, # but the query text doesn't matter for the test - actual = self.server.metadata.paginated_query('fake query endCursor hasNextPage', - variables={'first': 1, 'afterToken': None}) + actual = self.server.metadata.paginated_query( + "fake query endCursor hasNextPage", variables={"first": 1, "afterToken": None} + ) self.assertDictEqual(expected, actual) def test_metadata_query_ignore_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query('fake query') - datasources = actual['data'] + actual = self.server.metadata.query("fake query") + datasources = actual["data"] - self.assertNotEqual(actual.get('errors', None), None) - self.assertListEqual(EXPECTED_DICT_ERROR, actual['errors']) + self.assertNotEqual(actual.get("errors", None), None) + self.assertListEqual(EXPECTED_DICT_ERROR, actual["errors"]) self.assertDictEqual(EXPECTED_DICT, datasources) def test_metadata_query_abort_on_error(self): - with open(METADATA_QUERY_ERROR, 'rb') as f: + with open(METADATA_QUERY_ERROR, "rb") as f: response_json = json.loads(f.read().decode()) with requests_mock.mock() as m: m.post(self.baseurl, json=response_json) with self.assertRaises(GraphQLError) as e: - self.server.metadata.query('fake query', abort_on_error=True) + self.server.metadata.query("fake query", abort_on_error=True) self.assertListEqual(e.error, EXPECTED_DICT_ERROR) diff --git a/test/test_metrics.py b/test/test_metrics.py new file mode 100644 index 000000000..7628abb1a --- /dev/null +++ b/test/test_metrics.py @@ -0,0 +1,105 @@ +import unittest +import requests_mock +from pathlib import Path + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +assets = Path(__file__).parent / "assets" +METRICS_GET = assets / "metrics_get.xml" +METRICS_GET_BY_ID = assets / "metrics_get_by_id.xml" +METRICS_UPDATE = assets / "metrics_update.xml" + + +class TestMetrics(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.9" + + self.baseurl = self.server.metrics.baseurl + + def test_metrics_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=METRICS_GET.read_text()) + all_metrics, pagination_item = self.server.metrics.get() + + self.assertEqual(len(all_metrics), 2) + self.assertEqual(pagination_item.total_available, 27) + self.assertEqual(all_metrics[0].id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(all_metrics[0].name, "Example metric") + self.assertEqual(all_metrics[0].description, "Description of my metric.") + self.assertEqual(all_metrics[0].webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(all_metrics[0].created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(all_metrics[0].updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(all_metrics[0].suspended, True) + self.assertEqual(all_metrics[0].project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(all_metrics[0].project_name, "Default") + self.assertEqual(all_metrics[0].owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(all_metrics[0].view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(all_metrics[0].tags), 0) + + self.assertEqual(all_metrics[1].id, "721760d9-0aa4-4029-87ae-371c956cea07") + self.assertEqual(all_metrics[1].name, "Another Example metric") + self.assertEqual(all_metrics[1].description, "Description of another metric.") + self.assertEqual(all_metrics[1].webpage_url, "https://test/#/site/site-name/metrics/4") + self.assertEqual(format_datetime(all_metrics[1].created_at), "2020-01-03T01:02:03Z") + self.assertEqual(format_datetime(all_metrics[1].updated_at), "2020-01-04T01:02:03Z") + self.assertEqual(all_metrics[1].suspended, False) + self.assertEqual(all_metrics[1].project_id, "486e0de0-2258-45bd-99cf-b62013e19f4e") + self.assertEqual(all_metrics[1].project_name, "Assets") + self.assertEqual(all_metrics[1].owner_id, "1bbbc2b9-847d-443c-9a1f-dbcf112b8814") + self.assertEqual(all_metrics[1].view_id, "7dbfdb63-a6ca-4723-93ee-4fefc71992d3") + self.assertEqual(len(all_metrics[1].tags), 2) + self.assertIn("Test", all_metrics[1].tags) + self.assertIn("Asset", all_metrics[1].tags) + + def test_metrics_get_by_id(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{luid}", text=METRICS_GET_BY_ID.read_text()) + metric = self.server.metrics.get_by_id(luid) + + self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(metric.name, "Example metric") + self.assertEqual(metric.description, "Description of my metric.") + self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(metric.suspended, True) + self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.project_name, "Default") + self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(metric.tags), 0) + + def test_metrics_delete(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{luid}") + self.server.metrics.delete(luid) + + def test_metrics_update(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + metric = TSC.MetricItem() + metric._id = luid + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{luid}", text=METRICS_UPDATE.read_text()) + metric = self.server.metrics.update(metric) + + self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") + self.assertEqual(metric.name, "Example metric") + self.assertEqual(metric.description, "Description of my metric.") + self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") + self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") + self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") + self.assertEqual(metric.suspended, True) + self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.project_name, "Default") + self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") + self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") + self.assertEqual(len(metric.tags), 0) diff --git a/test/test_pager.py b/test/test_pager.py index 52089180d..b60559b2b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,32 +1,34 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_1.xml') -GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_2.xml') -GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, 'workbook_get_page_3.xml') +GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") +GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") +GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") class PagerTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl def test_pager_with_no_options(self): - with open(GET_XML_PAGE1, 'rb') as f: - page_1 = f.read().decode('utf-8') - with open(GET_XML_PAGE2, 'rb') as f: - page_2 = f.read().decode('utf-8') - with open(GET_XML_PAGE3, 'rb') as f: - page_3 = f.read().decode('utf-8') + with open(GET_XML_PAGE1, "rb") as f: + page_1 = f.read().decode("utf-8") + with open(GET_XML_PAGE2, "rb") as f: + page_2 = f.read().decode("utf-8") + with open(GET_XML_PAGE3, "rb") as f: + page_3 = f.read().decode("utf-8") with requests_mock.mock() as m: # Register Pager with default request options m.get(self.baseurl, text=page_1) @@ -42,17 +44,17 @@ def test_pager_with_no_options(self): # Let's check that workbook items aren't duplicates wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, 'Page1Workbook') - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb1.name, "Page1Workbook") + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") def test_pager_with_options(self): - with open(GET_XML_PAGE1, 'rb') as f: - page_1 = f.read().decode('utf-8') - with open(GET_XML_PAGE2, 'rb') as f: - page_2 = f.read().decode('utf-8') - with open(GET_XML_PAGE3, 'rb') as f: - page_3 = f.read().decode('utf-8') + with open(GET_XML_PAGE1, "rb") as f: + page_1 = f.read().decode("utf-8") + with open(GET_XML_PAGE2, "rb") as f: + page_2 = f.read().decode("utf-8") + with open(GET_XML_PAGE3, "rb") as f: + page_3 = f.read().decode("utf-8") with requests_mock.mock() as m: # Register Pager with some pages m.get(self.baseurl + "?pageNumber=1&pageSize=1", complete_qs=True, text=page_1) @@ -67,17 +69,17 @@ def test_pager_with_options(self): # Check that the workbooks are the 2 we think they should be wb2, wb3 = workbooks - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") # Starting on 1 with pagesize of 3 should get all 3 opts = TSC.RequestOptions(1, 3) workbooks = list(TSC.Pager(self.server.workbooks, opts)) self.assertTrue(len(workbooks) == 3) wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, 'Page1Workbook') - self.assertEqual(wb2.name, 'Page2Workbook') - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb1.name, "Page1Workbook") + self.assertEqual(wb2.name, "Page2Workbook") + self.assertEqual(wb3.name, "Page3Workbook") # Starting on 3 with pagesize of 1 should get the last item opts = TSC.RequestOptions(3, 1) @@ -85,4 +87,4 @@ def test_pager_with_options(self): self.assertTrue(len(workbooks) == 1) # Should have the last workbook wb3 = workbooks.pop() - self.assertEqual(wb3.name, 'Page3Workbook') + self.assertEqual(wb3.name, "Page3Workbook") diff --git a/test/test_project.py b/test/test_project.py index be43b063e..1d210eeb1 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -1,220 +1,231 @@ -import unittest import os +import unittest + import requests_mock -import tableauserverclient as TSC -from ._utils import read_xml_asset, read_xml_assets, asset +import tableauserverclient as TSC +from ._utils import read_xml_asset, asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = asset('project_get.xml') -UPDATE_XML = asset('project_update.xml') -SET_CONTENT_PERMISSIONS_XML = asset('project_content_permission.xml') -CREATE_XML = asset('project_create.xml') -POPULATE_PERMISSIONS_XML = 'project_populate_permissions.xml' -POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = 'project_populate_workbook_default_permissions.xml' -UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = 'project_update_datasource_default_permissions.xml' +GET_XML = asset("project_get.xml") +UPDATE_XML = asset("project_update.xml") +SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") +CREATE_XML = asset("project_create.xml") +POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" +POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" +UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" class ProjectTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.projects.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + 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) all_projects, pagination_item = self.server.projects.get() self.assertEqual(3, pagination_item.total_available) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_projects[0].id) - self.assertEqual('default', all_projects[0].name) - self.assertEqual('The default project that was automatically created by Tableau.', - all_projects[0].description) - self.assertEqual('ManagedByOwner', all_projects[0].content_permissions) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_projects[0].id) + self.assertEqual("default", all_projects[0].name) + self.assertEqual("The default project that was automatically created by Tableau.", all_projects[0].description) + self.assertEqual("ManagedByOwner", all_projects[0].content_permissions) self.assertEqual(None, all_projects[0].parent_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[0].owner_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[0].owner_id) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[1].id) - self.assertEqual('Tableau', all_projects[1].name) - self.assertEqual('ManagedByOwner', all_projects[1].content_permissions) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[1].id) + self.assertEqual("Tableau", all_projects[1].name) + self.assertEqual("ManagedByOwner", all_projects[1].content_permissions) self.assertEqual(None, all_projects[1].parent_id) - self.assertEqual('2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3', all_projects[1].owner_id) + self.assertEqual("2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3", all_projects[1].owner_id) - self.assertEqual('4cc52973-5e3a-4d1f-a4fb-5b5f73796edf', all_projects[2].id) - self.assertEqual('Tableau > Child 1', all_projects[2].name) - self.assertEqual('ManagedByOwner', all_projects[2].content_permissions) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', all_projects[2].parent_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', all_projects[2].owner_id) + self.assertEqual("4cc52973-5e3a-4d1f-a4fb-5b5f73796edf", all_projects[2].id) + self.assertEqual("Tableau > Child 1", all_projects[2].name) + self.assertEqual("ManagedByOwner", all_projects[2].content_permissions) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[2].parent_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[2].owner_id) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.projects.get) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) - self.server.projects.delete('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.delete(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + self.server.projects.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.projects.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.projects.delete, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/1d0304cd-3796-429f-b815-7258370b9b74', text=response_xml) - single_project = TSC.ProjectItem(name='Test Project', - content_permissions='LockedToProject', - description='Project created for testing', - parent_id='9a8f2265-70f3-4494-96c5-e5949d7a1120') - single_project._id = '1d0304cd-3796-429f-b815-7258370b9b74' + m.put(self.baseurl + "/1d0304cd-3796-429f-b815-7258370b9b74", text=response_xml) + single_project = TSC.ProjectItem( + name="Test Project", + content_permissions="LockedToProject", + description="Project created for testing", + parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", + ) + single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" single_project = self.server.projects.update(single_project) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_project.id) - self.assertEqual('Test Project', single_project.name) - self.assertEqual('Project created for testing', single_project.description) - self.assertEqual('LockedToProject', single_project.content_permissions) - self.assertEqual('9a8f2265-70f3-4494-96c5-e5949d7a1120', single_project.parent_id) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) + self.assertEqual("Test Project", single_project.name) + self.assertEqual("Project created for testing", single_project.description) + self.assertEqual("LockedToProject", single_project.content_permissions) + self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", single_project.parent_id) - def test_content_permission_locked_to_project_without_nested(self): - with open(SET_CONTENT_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_content_permission_locked_to_project_without_nested(self) -> None: + with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', text=response_xml) - project_item = TSC.ProjectItem(name='Test Project Permissions', - content_permissions='LockedToProjectWithoutNested', - description='Project created for testing', - parent_id='7687bc43-a543-42f3-b86f-80caed03a813') - project_item._id = 'cb3759e5-da4a-4ade-b916-7e2b4ea7ec86' + m.put(self.baseurl + "/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", text=response_xml) + project_item = TSC.ProjectItem( + name="Test Project Permissions", + content_permissions="LockedToProjectWithoutNested", + description="Project created for testing", + parent_id="7687bc43-a543-42f3-b86f-80caed03a813", + ) + project_item._id = "cb3759e5-da4a-4ade-b916-7e2b4ea7ec86" project_item = self.server.projects.update(project_item) - self.assertEqual('cb3759e5-da4a-4ade-b916-7e2b4ea7ec86', project_item.id) - self.assertEqual('Test Project Permissions', project_item.name) - self.assertEqual('Project created for testing', project_item.description) - self.assertEqual('LockedToProjectWithoutNested', project_item.content_permissions) - self.assertEqual('7687bc43-a543-42f3-b86f-80caed03a813', project_item.parent_id) + self.assertEqual("cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", project_item.id) + self.assertEqual("Test Project Permissions", project_item.name) + self.assertEqual("Project created for testing", project_item.description) + self.assertEqual("LockedToProjectWithoutNested", project_item.content_permissions) + self.assertEqual("7687bc43-a543-42f3-b86f-80caed03a813", project_item.parent_id) - def test_update_datasource_default_permission(self): + def test_update_datasource_default_permission(self) -> None: response_xml = read_xml_asset(UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources', - text=response_xml) - project = TSC.ProjectItem('test-project') - project._id = 'b4065286-80f0-11ea-af1b-cb7191f48e45' + m.put( + self.baseurl + "/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources", + text=response_xml, + ) + project = TSC.ProjectItem("test-project") + project._id = "b4065286-80f0-11ea-af1b-cb7191f48e45" - group = TSC.GroupItem('test-group') - group._id = 'b4488bce-80f0-11ea-af1c-976d0c1dab39' + group = TSC.GroupItem("test-group") + group._id = "b4488bce-80f0-11ea-af1c-976d0c1dab39" capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - rules = [TSC.PermissionsRule( - grantee=group, - capabilities=capabilities - )] + rules = [TSC.PermissionsRule(grantee=group.to_reference(), capabilities=capabilities)] new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - self.assertEqual('b4488bce-80f0-11ea-af1c-976d0c1dab39', new_rules[0].grantee.id) + self.assertEqual("b4488bce-80f0-11ea-af1c-976d0c1dab39", new_rules[0].grantee.id) updated_capabilities = new_rules[0].capabilities self.assertEqual(4, len(updated_capabilities)) - self.assertEqual('Deny', updated_capabilities['ExportXml']) - self.assertEqual('Allow', updated_capabilities['Read']) - self.assertEqual('Allow', updated_capabilities['Write']) - self.assertEqual('Allow', updated_capabilities['Connect']) + self.assertEqual("Deny", updated_capabilities["ExportXml"]) + self.assertEqual("Allow", updated_capabilities["Read"]) + self.assertEqual("Allow", updated_capabilities["Write"]) + self.assertEqual("Allow", updated_capabilities["Connect"]) - def test_update_missing_id(self): - single_project = TSC.ProjectItem('test') + def test_update_missing_id(self) -> None: + single_project = TSC.ProjectItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create(self) -> None: + + 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_project = TSC.ProjectItem(name='Test Project', description='Project created for testing') - new_project.content_permissions = 'ManagedByOwner' - new_project.parent_id = '9a8f2265-70f3-4494-96c5-e5949d7a1120' + new_project = TSC.ProjectItem(name="Test Project", description="Project created for testing") + new_project.content_permissions = "ManagedByOwner" + new_project.parent_id = "9a8f2265-70f3-4494-96c5-e5949d7a1120" new_project = self.server.projects.create(new_project) - self.assertEqual('ccbea03f-77c4-4209-8774-f67bc59c3cef', new_project.id) - self.assertEqual('Test Project', new_project.name) - self.assertEqual('Project created for testing', new_project.description) - self.assertEqual('ManagedByOwner', new_project.content_permissions) - self.assertEqual('9a8f2265-70f3-4494-96c5-e5949d7a1120', new_project.parent_id) + self.assertEqual("ccbea03f-77c4-4209-8774-f67bc59c3cef", new_project.id) + self.assertEqual("Test Project", new_project.name) + self.assertEqual("Project created for testing", new_project.description) + self.assertEqual("ManagedByOwner", new_project.content_permissions) + self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", new_project.parent_id) - def test_create_missing_name(self): - self.assertRaises(ValueError, TSC.ProjectItem, '') + def test_create_missing_name(self) -> None: + self.assertRaises(ValueError, TSC.ProjectItem, "") - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) - single_project = TSC.ProjectItem('Project3') - single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.projects.populate_permissions(single_project) permissions = single_project.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }) + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) - def test_populate_workbooks(self): + def test_populate_workbooks(self) -> None: response_xml = read_xml_asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML) with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', - text=response_xml) - single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') - single_project._owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml + ) + single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) permissions = single_project.default_workbook_permissions rule1 = permissions.pop() - self.assertEqual('c8f2773a-c83a-11e8-8c8f-33e6d787b506', rule1.grantee.id) - self.assertEqual('group', rule1.grantee.tag_name) - self.assertDictEqual(rule1.capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - }) - - def test_delete_permission(self): - with open(asset(POPULATE_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule1.grantee.id) + self.assertEqual("group", rule1.grantee.tag_name) + self.assertDictEqual( + rule1.capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + }, + ) + + def test_delete_permission(self) -> None: + with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions', text=response_xml) + m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) - single_group = TSC.GroupItem('Group1') - single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - single_project = TSC.ProjectItem('Project3') - single_project._id = '0448d2ed-590d-4fa0-b272-a2a8a24555b5' + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" self.server.projects.populate_permissions(single_project) permissions = single_project.permissions @@ -226,30 +237,28 @@ def test_delete_permission(self): if permission.grantee.id == single_group._id: capabilities = permission.capabilities - rules = TSC.PermissionsRule( - grantee=single_group, - capabilities=capabilities - ) + rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) - endpoint = '{}/permissions/groups/{}'.format(single_project._id, single_group._id) - m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) + endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) - def test_delete_workbook_default_permission(self): - with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_delete_workbook_default_permission(self) -> None: + with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks', - text=response_xml) + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml + ) - single_group = TSC.GroupItem('Group1') - single_group._id = 'c8f2773a-c83a-11e8-8c8f-33e6d787b506' + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - single_project = TSC.ProjectItem('test', '1d0304cd-3796-429f-b815-7258370b9b74') - single_project._owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_project._id = '9dbd2263-16b5-46e1-9c43-a76bb8ab65fb' + single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) permissions = single_project.default_workbook_permissions @@ -261,39 +270,34 @@ def test_delete_workbook_default_permission(self): TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - # Interact/Edit TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - # Edit TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, } - rules = TSC.PermissionsRule( - grantee=single_group, - capabilities=capabilities - ) - - endpoint = '{}/default-permissions/workbooks/groups/{}'.format(single_project._id, single_group._id) - m.delete('{}/{}/Read/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportImage/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportData/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ViewComments/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/AddComment/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Filter/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ViewUnderlyingData/Deny'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ShareView/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/WebAuthoring/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Write/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ExportXml/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ChangeHierarchy/Allow'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/Delete/Deny'.format(self.baseurl, endpoint), status_code=204) - m.delete('{}/{}/ChangePermissions/Allow'.format(self.baseurl, endpoint), status_code=204) + rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + + endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_project_model.py b/test/test_project_model.py index 55cf20b26..a8b96dc4f 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 281f3fbca..58d6329db 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -3,7 +3,7 @@ try: from unittest import mock except ImportError: - import mock + import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory from tableauserverclient.server.endpoint import Endpoint @@ -13,51 +13,50 @@ class BugFix257(unittest.TestCase): def test_empty_request_works(self): result = factory.EmptyRequest().empty_req() - self.assertEqual(b'', result) + self.assertEqual(b"", result) class BugFix273(unittest.TestCase): def test_binary_log_truncated(self): - class FakeResponse(object): - headers = {'Content-Type': 'application/octet-stream'} - content = b'\x1337' * 1000 + headers = {"Content-Type": "application/octet-stream"} + content = b"\x1337" * 1000 status_code = 200 server_response = FakeResponse() - self.assertEqual(Endpoint._safe_to_log(server_response), '[Truncated File Contents]') + self.assertEqual(Endpoint._safe_to_log(server_response), "[Truncated File Contents]") class FileSysHelpers(unittest.TestCase): def test_to_filename(self): invalid = [ "23brhafbjrjhkbbea.txt", - 'a_b_C.txt', - 'windows space.txt', - 'abc#def.txt', - 't@bL3A()', + "a_b_C.txt", + "windows space.txt", + "abc#def.txt", + "t@bL3A()", ] valid = [ "23brhafbjrjhkbbea.txt", - 'a_b_C.txt', - 'windows space.txt', - 'abcdef.txt', - 'tbL3A', + "a_b_C.txt", + "windows space.txt", + "abcdef.txt", + "tbL3A", ] self.assertTrue(all([(to_filename(i) == v) for i, v in zip(invalid, valid)])) def test_make_download_path(self): - no_file_path = (None, 'file.ext') - has_file_path_folder = ('/root/folder/', 'file.ext') - has_file_path_file = ('out', 'file.ext') + no_file_path = (None, "file.ext") + has_file_path_folder = ("/root/folder/", "file.ext") + has_file_path_file = ("outx", "file.ext") - self.assertEqual('file.ext', make_download_path(*no_file_path)) - self.assertEqual('out.ext', make_download_path(*has_file_path_file)) + self.assertEqual("file.ext", make_download_path(*no_file_path)) + self.assertEqual("outx.ext", make_download_path(*has_file_path_file)) - with mock.patch('os.path.isdir') as mocked_isdir: + with mock.patch("os.path.isdir") as mocked_isdir: mocked_isdir.return_value = True - self.assertEqual('/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_request_option.py b/test/test_request_option.py index 37b4fc945..ed8d55bb0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -1,36 +1,38 @@ -import unittest import os import re -import requests +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -PAGINATION_XML = os.path.join(TEST_ASSET_DIR, 'request_option_pagination.xml') -PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, 'request_option_page_number.xml') -PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, 'request_option_page_size.xml') -FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, 'request_option_filter_equals.xml') -FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, 'request_option_filter_tags_in.xml') -FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, 'request_option_filter_tags_in.xml') +PAGINATION_XML = os.path.join(TEST_ASSET_DIR, "request_option_pagination.xml") +PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") +PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") +FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") +FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") +FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") +SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") class RequestOptionTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin self.server.version = "3.10" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = '{0}/{1}'.format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) def test_pagination(self): - with open(PAGINATION_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGINATION_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageNumber=1&pageSize=10', text=response_xml) + m.get(self.baseurl + "/views?pageNumber=1&pageSize=10", text=response_xml) req_option = TSC.RequestOptions().page_size(10) all_views, pagination_item = self.server.views.get(req_option) @@ -40,10 +42,10 @@ def test_pagination(self): self.assertEqual(10, len(all_views)) def test_page_number(self): - with open(PAGE_NUMBER_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGE_NUMBER_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageNumber=3', text=response_xml) + m.get(self.baseurl + "/views?pageNumber=3", text=response_xml) req_option = TSC.RequestOptions().page_number(3) all_views, pagination_item = self.server.views.get(req_option) @@ -53,10 +55,10 @@ def test_page_number(self): self.assertEqual(10, len(all_views)) def test_page_size(self): - with open(PAGE_SIZE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(PAGE_SIZE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/views?pageSize=5', text=response_xml) + m.get(self.baseurl + "/views?pageSize=5", text=response_xml) req_option = TSC.RequestOptions().page_size(5) all_views, pagination_item = self.server.views.get(req_option) @@ -66,74 +68,86 @@ def test_page_size(self): self.assertEqual(5, len(all_views)) def test_filter_equals(self): - with open(FILTER_EQUALS, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_EQUALS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=name:eq:RESTAPISample', text=response_xml) + m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, 'RESTAPISample')) + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "RESTAPISample") + ) matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(2, pagination_item.total_available) - self.assertEqual('RESTAPISample', matching_workbooks[0].name) - self.assertEqual('RESTAPISample', matching_workbooks[1].name) + self.assertEqual("RESTAPISample", matching_workbooks[0].name) + self.assertEqual("RESTAPISample", matching_workbooks[1].name) def test_filter_equals_shorthand(self): - with open(FILTER_EQUALS, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_EQUALS, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=name:eq:RESTAPISample', text=response_xml) - matching_workbooks = self.server.workbooks.filter(name='RESTAPISample').order_by("name") + m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) + matching_workbooks = self.server.workbooks.filter(name="RESTAPISample").order_by("name") self.assertEqual(2, matching_workbooks.total_available) - self.assertEqual('RESTAPISample', matching_workbooks[0].name) - self.assertEqual('RESTAPISample', matching_workbooks[1].name) + self.assertEqual("RESTAPISample", matching_workbooks[0].name) + self.assertEqual("RESTAPISample", matching_workbooks[1].name) def test_filter_tags_in(self): - with open(FILTER_TAGS_IN, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_TAGS_IN, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=tags:in:[sample,safari,weather]', text=response_xml) + m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, - ['sample', 'safari', 'weather'])) + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] + ) + ) matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(['weather']), matching_workbooks[0].tags) - self.assertEqual(set(['safari']), matching_workbooks[1].tags) - self.assertEqual(set(['sample']), matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_filter_tags_in_shorthand(self): - with open(FILTER_TAGS_IN, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_TAGS_IN, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/workbooks?filter=tags:in:[sample,safari,weather]', text=response_xml) - matching_workbooks = self.server.workbooks.filter(tags__in=['sample', 'safari', 'weather']) + m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) + matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(['weather']), matching_workbooks[0].tags) - self.assertEqual(set(['safari']), matching_workbooks[1].tags) - self.assertEqual(set(['sample']), matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_invalid_shorthand_option(self): with self.assertRaises(ValueError): - self.server.workbooks.filter(nonexistant__in=['sample', 'safari']) + self.server.workbooks.filter(nonexistant__in=["sample", "safari"]) def test_multiple_filter_options(self): - with open(FILTER_MULTIPLE, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_MULTIPLE, "rb") as f: + response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times with requests_mock.mock() as m: # Sometimes pep8 requires you to do things you might not otherwise do - url = ''.join((self.baseurl, '/workbooks?pageNumber=1&pageSize=100&', - 'filter=name:eq:foo,tags:in:[sample,safari,weather]')) + url = "".join( + ( + self.baseurl, + "/workbooks?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", + ) + ) m.get(url, text=response_xml) req_option = TSC.RequestOptions() - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, - ['sample', 'safari', 'weather'])) - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, 'foo')) + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] + ) + ) + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) for _ in range(100): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -145,16 +159,15 @@ def test_double_query_params(self): url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('queryparamexists=true', resp.request.query)) - self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) - self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + self.assertTrue(re.search("queryparamexists=true", resp.request.query)) + self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) + self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) # Test req_options for versions below 3.7 def test_filter_sort_legacy(self): @@ -164,16 +177,15 @@ def test_filter_sort_legacy(self): url = self.baseurl + "/views?queryParamExists=true" opts = TSC.RequestOptions() - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('queryparamexists=true', resp.request.query)) - self.assertTrue(re.search('filter=tags:in:%5bstocks,market%5d', resp.request.query)) - self.assertTrue(re.search('sort=name:asc', resp.request.query)) + self.assertTrue(re.search("queryparamexists=true", resp.request.query)) + self.assertTrue(re.search("filter=tags:in:%5bstocks,market%5d", resp.request.query)) + self.assertTrue(re.search("sort=name:asc", resp.request.query)) def test_vf(self): with requests_mock.mock() as m: @@ -185,9 +197,9 @@ def test_vf(self): opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('vf_name1%23=value1', resp.request.query)) - self.assertTrue(re.search('vf_name2%24=value2', resp.request.query)) - self.assertTrue(re.search('type=tabloid', resp.request.query)) + self.assertTrue(re.search("vf_name1%23=value1", resp.request.query)) + self.assertTrue(re.search("vf_name2%24=value2", resp.request.query)) + self.assertTrue(re.search("type=tabloid", resp.request.query)) # Test req_options for versions beloe 3.7 def test_vf_legacy(self): @@ -201,9 +213,9 @@ def test_vf_legacy(self): opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('vf_name1@=value1', resp.request.query)) - self.assertTrue(re.search('vf_name2\\$=value2', resp.request.query)) - self.assertTrue(re.search('type=tabloid', resp.request.query)) + self.assertTrue(re.search("vf_name1@=value1", resp.request.query)) + self.assertTrue(re.search("vf_name2\\$=value2", resp.request.query)) + self.assertTrue(re.search("type=tabloid", resp.request.query)) def test_all_fields(self): with requests_mock.mock() as m: @@ -213,20 +225,51 @@ def test_all_fields(self): opts._all_fields = True resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search('fields=_all_', resp.request.query)) + self.assertTrue(re.search("fields=_all_", resp.request.query)) def test_multiple_filter_options_shorthand(self): - with open(FILTER_MULTIPLE, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(FILTER_MULTIPLE, "rb") as f: + response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times with requests_mock.mock() as m: # Sometimes pep8 requires you to do things you might not otherwise do - url = ''.join((self.baseurl, '/workbooks?pageNumber=1&pageSize=100&', - 'filter=name:eq:foo,tags:in:[sample,safari,weather]')) + url = "".join( + ( + self.baseurl, + "/workbooks?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", + ) + ) m.get(url, text=response_xml) for _ in range(100): - matching_workbooks = self.server.workbooks.filter( - tags__in=['sample', 'safari', 'weather'], name='foo' - ) + matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) + + def test_slicing_queryset(self): + with open(SLICING_QUERYSET, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/views?pageNumber=1", text=response_xml) + all_views = self.server.views.all() + + self.assertEqual(10, len(all_views[::])) + self.assertEqual(5, len(all_views[::2])) + self.assertEqual(8, len(all_views[2:])) + self.assertEqual(2, len(all_views[:2])) + self.assertEqual(3, len(all_views[2:5])) + self.assertEqual(3, len(all_views[-3:])) + self.assertEqual(3, len(all_views[-6:-3])) + self.assertEqual(3, len(all_views[3:6:-1])) + self.assertEqual(3, len(all_views[6:3:-1])) + self.assertEqual(10, len(all_views[::-1])) + self.assertEqual(all_views[3:6], list(reversed(all_views[3:6:-1]))) + + self.assertEqual(all_views[-3].id, "2df55de2-3a2d-4e34-b515-6d4e70b830e9") + + with self.assertRaises(IndexError): + all_views[100] + + def test_queryset_filter_args_error(self): + with self.assertRaises(RuntimeError): + workbooks = self.server.workbooks.filter("argument") diff --git a/test/test_requests.py b/test/test_requests.py index 2976e8f3e..82859dd26 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -1,20 +1,20 @@ -import unittest import re +import unittest + import requests import requests_mock import tableauserverclient as TSC - from tableauserverclient.server.endpoint.exceptions import InternalServerError, NonXMLResponseError class RequestTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl @@ -25,26 +25,30 @@ def test_make_get_request(self): opts = TSC.RequestOptions(pagesize=13, pagenumber=15) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('pagenumber=15', resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("pagenumber=15", resp.request.query)) def test_make_post_request(self): with requests_mock.mock() as m: m.post(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - resp = self.server.workbooks._make_request(requests.post, url, content=b'1337', - auth_token='j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM', - content_type='multipart/mixed') - self.assertEqual(resp.request.headers['x-tableau-auth'], 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM') - self.assertEqual(resp.request.headers['content-type'], 'multipart/mixed') - self.assertEqual(resp.request.body, b'1337') + resp = self.server.workbooks._make_request( + requests.post, + url, + content=b"1337", + auth_token="j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM", + content_type="multipart/mixed", + ) + self.assertEqual(resp.request.headers["x-tableau-auth"], "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM") + self.assertEqual(resp.request.headers["content-type"], "multipart/mixed") + self.assertEqual(resp.request.body, b"1337") # Test that 500 server errors are handled properly def test_internal_server_error(self): self.server.version = "3.2" 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) + m.register_uri("GET", self.server.server_info.baseurl, status_code=500, text=server_response) self.assertRaisesRegex(InternalServerError, server_response, self.server.server_info.get) # Test that non-xml server errors are handled properly @@ -52,5 +56,5 @@ def test_non_xml_error(self): self.server.version = "3.2" 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) + m.register_uri("GET", self.server.server_info.baseurl, status_code=499, text=server_response) self.assertRaisesRegex(NonXMLResponseError, server_response, self.server.server_info.get) diff --git a/test/test_schedule.py b/test/test_schedule.py index 3a84caeb9..807467918 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,13 +1,16 @@ -from datetime import time -import unittest import os +import unittest +from datetime import time + import requests_mock + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -17,14 +20,16 @@ ADD_WORKBOOK_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook.xml") ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") +ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") -WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') -DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'datasource_get_by_id.xml') +WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") +DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") +FLOW_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "flow_get_by_id.xml") class ScheduleTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test") + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -32,7 +37,7 @@ def setUp(self): self.baseurl = self.server.schedules.baseurl - def test_get(self): + def test_get(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -71,7 +76,7 @@ def test_get(self): self.assertEqual("Flow", flow.schedule_type) self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) - def test_get_empty(self): + def test_get_empty(self) -> None: with open(GET_EMPTY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -81,21 +86,38 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_schedules) - def test_delete(self): + def test_get_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Weekday early mornings", schedule.name) + self.assertEqual("Active", schedule.state) + + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) self.server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") - def test_create_hourly(self): + def test_create_hourly(self) -> None: with open(CREATE_HOURLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), - end_time=time(23, 0), - interval_value=2) - new_schedule = TSC.ScheduleItem("hourly-schedule-1", 50, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + new_schedule = TSC.ScheduleItem( + "hourly-schedule-1", + 50, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + hourly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("5f42be25-8a43-47ba-971a-63f2d4e7029c", new_schedule.id) @@ -108,17 +130,22 @@ def test_create_hourly(self): self.assertEqual("2016-09-16T01:30:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) - self.assertEqual(time(23), new_schedule.interval_item.end_time) - self.assertEqual("8", new_schedule.interval_item.interval) + self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] + self.assertEqual("8", new_schedule.interval_item.interval) # type: ignore[union-attr] - def test_create_daily(self): + def test_create_daily(self) -> None: with open(CREATE_DAILY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) daily_interval = TSC.DailyInterval(time(4, 50)) - new_schedule = TSC.ScheduleItem("daily-schedule-1", 90, TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) + new_schedule = TSC.ScheduleItem( + "daily-schedule-1", + 90, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, + daily_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("907cae38-72fd-417c-892a-95540c4664cd", new_schedule.id) @@ -132,16 +159,21 @@ def test_create_daily(self): self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) - def test_create_weekly(self): + def test_create_weekly(self) -> None: with open(CREATE_WEEKLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) - weekly_interval = TSC.WeeklyInterval(time(9, 15), TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Wednesday, - TSC.IntervalItem.Day.Friday) - new_schedule = TSC.ScheduleItem("weekly-schedule-1", 80, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, weekly_interval) + weekly_interval = TSC.WeeklyInterval( + time(9, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday + ) + new_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 80, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + weekly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("1adff386-6be0-4958-9f81-a35e676932bf", new_schedule.id) @@ -154,20 +186,24 @@ def test_create_weekly(self): self.assertEqual("2016-09-16T16:15:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Wednesday", "Friday"), - new_schedule.interval_item.interval) + self.assertEqual(("Monday", "Wednesday", "Friday"), new_schedule.interval_item.interval) self.assertEqual(2, len(new_schedule.warnings)) self.assertEqual("warning 1", new_schedule.warnings[0]) self.assertEqual("warning 2", new_schedule.warnings[1]) - def test_create_monthly(self): + def test_create_monthly(self) -> None: with open(CREATE_MONTHLY_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=response_xml) monthly_interval = TSC.MonthlyInterval(time(7), 12) - new_schedule = TSC.ScheduleItem("monthly-schedule-1", 20, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Serial, monthly_interval) + new_schedule = TSC.ScheduleItem( + "monthly-schedule-1", + 20, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, + monthly_interval, + ) new_schedule = self.server.schedules.create(new_schedule) self.assertEqual("e06a7c75-5576-4f68-882d-8909d0219326", new_schedule.id) @@ -180,17 +216,21 @@ def test_create_monthly(self): self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual("12", new_schedule.interval_item.interval) + self.assertEqual("12", new_schedule.interval_item.interval) # type: ignore[union-attr] - def test_update(self): + def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/7bea1766-1543-4052-9753-9d224bc069b5', text=response_xml) - new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, - TSC.IntervalItem.Day.Friday) - single_schedule = TSC.ScheduleItem("weekly-schedule-1", 90, TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, new_interval) + m.put(self.baseurl + "/7bea1766-1543-4052-9753-9d224bc069b5", text=response_xml) + new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Friday) + single_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 90, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + new_interval, + ) single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" single_schedule.state = TSC.ScheduleItem.State.Suspended single_schedule = self.server.schedules.update(single_schedule) @@ -203,12 +243,11 @@ def test_update(self): self.assertEqual("2016-09-16T14:00:00Z", format_datetime(single_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, single_schedule.execution_order) self.assertEqual(time(7), single_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Friday"), - single_schedule.interval_item.interval) + self.assertEqual(("Monday", "Friday"), single_schedule.interval_item.interval) # type: ignore[union-attr] self.assertEqual(TSC.ScheduleItem.State.Suspended, single_schedule.state) # Tests calling update with a schedule item returned from the server - def test_update_after_get(self): + def test_update_after_get(self) -> None: with open(GET_XML, "rb") as f: get_response_xml = f.read().decode("utf-8") with open(UPDATE_XML, "rb") as f: @@ -220,19 +259,19 @@ def test_update_after_get(self): all_schedules, pagination_item = self.server.schedules.get() schedule_item = all_schedules[0] self.assertEqual(TSC.ScheduleItem.State.Active, schedule_item.state) - self.assertEqual('Weekday early mornings', schedule_item.name) + self.assertEqual("Weekday early mornings", schedule_item.name) # Update the schedule with requests_mock.mock() as m: - m.put(self.baseurl + '/c9cff7f9-309c-4361-99ff-d4ba8c9f5467', text=update_response_xml) + m.put(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", text=update_response_xml) schedule_item.state = TSC.ScheduleItem.State.Suspended - schedule_item.name = 'newName' + schedule_item.name = "newName" schedule_item = self.server.schedules.update(schedule_item) self.assertEqual(TSC.ScheduleItem.State.Suspended, schedule_item.state) - self.assertEqual('weekly-schedule-1', schedule_item.name) + self.assertEqual("weekly-schedule-1", schedule_item.name) - def test_add_workbook(self): + def test_add_workbook(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -241,13 +280,13 @@ def test_add_workbook(self): with open(ADD_WORKBOOK_TO_SCHEDULE, "rb") as f: add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + result = self.server.schedules.add_to_schedule("foo", workbook=workbook) self.assertEqual(0, len(result), "Added properly") - def test_add_workbook_with_warnings(self): + def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -256,14 +295,14 @@ def test_add_workbook_with_warnings(self): with open(ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS, "rb") as f: add_workbook_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + '/bar', text=workbook_response) - m.put(baseurl + '/foo/workbooks', text=add_workbook_response) + m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', workbook=workbook) + result = self.server.schedules.add_to_schedule("foo", workbook=workbook) self.assertEqual(1, len(result), "Not added properly") self.assertEqual(2, len(result[0].warnings)) - def test_add_datasource(self): + def test_add_datasource(self) -> None: self.server.version = "2.8" baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) @@ -272,8 +311,23 @@ def test_add_datasource(self): with open(ADD_DATASOURCE_TO_SCHEDULE, "rb") as f: add_datasource_response = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.datasources.baseurl + '/bar', text=datasource_response) - m.put(baseurl + '/foo/datasources', text=add_datasource_response) + m.get(self.server.datasources.baseurl + "/bar", text=datasource_response) + m.put(baseurl + "/foo/datasources", text=add_datasource_response) datasource = self.server.datasources.get_by_id("bar") - result = self.server.schedules.add_to_schedule('foo', datasource=datasource) + result = self.server.schedules.add_to_schedule("foo", datasource=datasource) + self.assertEqual(0, len(result), "Added properly") + + def test_add_flow(self) -> None: + self.server.version = "3.3" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + + with open(FLOW_GET_BY_ID_XML, "rb") as f: + flow_response = f.read().decode("utf-8") + with open(ADD_FLOW_TO_SCHEDULE, "rb") as f: + add_flow_response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.flows.baseurl + "/bar", text=flow_response) + m.put(baseurl + "/foo/flows", text=flow_response) + flow = self.server.flows.get_by_id("bar") + result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") diff --git a/test/test_server_info.py b/test/test_server_info.py index 3dadff7c1..80b071e75 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -1,62 +1,64 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, 'server_info_get.xml') -SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, 'server_info_25.xml') -SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, 'server_info_404.xml') -SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, 'server_info_auth_info.xml') +SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, "server_info_get.xml") +SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") +SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") +SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") class ServerInfoTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.baseurl = self.server.server_info.baseurl self.server.version = "2.4" def test_server_info_get(self): - with open(SERVER_INFO_GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.server.server_info.baseurl, text=response_xml) actual = self.server.server_info.get() - self.assertEqual('10.1.0', actual.product_version) - self.assertEqual('10100.16.1024.2100', actual.build_number) - self.assertEqual('2.4', actual.rest_api_version) + self.assertEqual("10.1.0", actual.product_version) + self.assertEqual("10100.16.1024.2100", actual.build_number) + self.assertEqual("2.4", actual.rest_api_version) def test_server_info_use_highest_version_downgrades(self): - with open(SERVER_INFO_AUTH_INFO_XML, 'rb') as f: + with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: # This is the auth.xml endpoint present back to 9.0 Servers - auth_response_xml = f.read().decode('utf-8') - with open(SERVER_INFO_404, 'rb') as f: + auth_response_xml = f.read().decode("utf-8") + with open(SERVER_INFO_404, "rb") as f: # 10.1 serverInfo response - si_response_xml = f.read().decode('utf-8') + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: # Return a 404 for serverInfo so we can pretend this is an old Server m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) self.server.use_server_version() - self.assertEqual(self.server.version, '2.2') + self.assertEqual(self.server.version, "2.2") def test_server_info_use_highest_version_upgrades(self): - with open(SERVER_INFO_GET_XML, 'rb') as f: - si_response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_GET_XML, "rb") as f: + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) # Pretend we're old - self.server.version = '2.0' + self.server.version = "2.0" self.server.use_server_version() # Did we upgrade to 2.4? - self.assertEqual(self.server.version, '2.4') + self.assertEqual(self.server.version, "2.4") def test_server_use_server_version_flag(self): - with open(SERVER_INFO_25_XML, 'rb') as f: - si_response_xml = f.read().decode('utf-8') + with open(SERVER_INFO_25_XML, "rb") as f: + si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('http://test/api/2.4/serverInfo', text=si_response_xml) - server = TSC.Server('http://test', use_server_version=True) - self.assertEqual(server.version, '2.5') + m.get("http://test/api/2.4/serverInfo", text=si_response_xml) + server = TSC.Server("http://test", use_server_version=True) + self.assertEqual(server.version, "2.5") diff --git a/test/test_site.py b/test/test_site.py index 8fbb4eda3..23eb99ddd 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,39 +1,41 @@ -import unittest import os.path +import unittest + import requests_mock + import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = os.path.join(TEST_ASSET_DIR, 'site_get.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'site_get_by_id.xml') -GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, 'site_get_by_name.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'site_update.xml') -CREATE_XML = os.path.join(TEST_ASSET_DIR, 'site_create.xml') +GET_XML = os.path.join(TEST_ASSET_DIR, "site_get.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_id.xml") +GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") +CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") class SiteTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) self.server.version = "3.10" # Fake signin - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' - self.server._site_id = '0626857c-1def-4503-a7d8-7907c3ff9d9f' + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server._site_id = "0626857c-1def-4503-a7d8-7907c3ff9d9f" self.baseurl = self.server.sites.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + 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) all_sites, pagination_item = self.server.sites.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', all_sites[0].id) - self.assertEqual('Active', all_sites[0].state) - self.assertEqual('Default', all_sites[0].name) - self.assertEqual('ContentOnly', all_sites[0].admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", all_sites[0].id) + self.assertEqual("Active", all_sites[0].state) + self.assertEqual("Default", all_sites[0].name) + self.assertEqual("ContentOnly", all_sites[0].admin_mode) self.assertEqual(False, all_sites[0].revision_history_enabled) self.assertEqual(True, all_sites[0].subscribe_others_enabled) self.assertEqual(25, all_sites[0].revision_limit) @@ -43,10 +45,10 @@ def test_get(self): self.assertEqual(False, all_sites[0].editing_flows_enabled) self.assertEqual(False, all_sites[0].scheduling_flows_enabled) self.assertEqual(True, all_sites[0].allow_subscription_attachments) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', all_sites[1].id) - self.assertEqual('Active', all_sites[1].state) - self.assertEqual('Samples', all_sites[1].name) - self.assertEqual('ContentOnly', all_sites[1].admin_mode) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", all_sites[1].id) + self.assertEqual("Active", all_sites[1].state) + self.assertEqual("Samples", all_sites[1].name) + self.assertEqual("ContentOnly", all_sites[1].admin_mode) self.assertEqual(False, all_sites[1].revision_history_enabled) self.assertEqual(True, all_sites[1].subscribe_others_enabled) self.assertEqual(False, all_sites[1].guest_access_enabled) @@ -61,21 +63,21 @@ def test_get(self): self.assertEqual(False, all_sites[1].flows_enabled) self.assertEqual(None, all_sites[1].data_acceleration_mode) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.sites.get) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dad65087-b08b-4603-af4e-2887b8aafc67', text=response_xml) - single_site = self.server.sites.get_by_id('dad65087-b08b-4603-af4e-2887b8aafc67') + m.get(self.baseurl + "/dad65087-b08b-4603-af4e-2887b8aafc67", text=response_xml) + single_site = self.server.sites.get_by_id("dad65087-b08b-4603-af4e-2887b8aafc67") - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', single_site.id) - self.assertEqual('Active', single_site.state) - self.assertEqual('Default', single_site.name) - self.assertEqual('ContentOnly', single_site.admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual("Active", single_site.state) + self.assertEqual("Default", single_site.name) + self.assertEqual("ContentOnly", single_site.admin_mode) self.assertEqual(False, single_site.revision_history_enabled) self.assertEqual(True, single_site.subscribe_others_enabled) self.assertEqual(False, single_site.disable_subscriptions) @@ -83,61 +85,80 @@ def test_get_by_id(self): self.assertEqual(False, single_site.commenting_mentions_enabled) self.assertEqual(True, single_site.catalog_obfuscation_enabled) - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.sites.get_by_id, '') + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.sites.get_by_id, "") - def test_get_by_name(self): - with open(GET_BY_NAME_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_name(self) -> None: + with open(GET_BY_NAME_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/testsite?key=name', text=response_xml) - single_site = self.server.sites.get_by_name('testsite') + m.get(self.baseurl + "/testsite?key=name", text=response_xml) + single_site = self.server.sites.get_by_name("testsite") - self.assertEqual('dad65087-b08b-4603-af4e-2887b8aafc67', single_site.id) - self.assertEqual('Active', single_site.state) - self.assertEqual('testsite', single_site.name) - self.assertEqual('ContentOnly', single_site.admin_mode) + self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual("Active", single_site.state) + self.assertEqual("testsite", single_site.name) + self.assertEqual("ContentOnly", single_site.admin_mode) self.assertEqual(False, single_site.revision_history_enabled) self.assertEqual(True, single_site.subscribe_others_enabled) self.assertEqual(False, single_site.disable_subscriptions) - def test_get_by_name_missing_name(self): - self.assertRaises(ValueError, self.server.sites.get_by_name, '') + def test_get_by_name_missing_name(self) -> None: + self.assertRaises(ValueError, self.server.sites.get_by_name, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/6b7179ba-b82b-4f0f-91ed-812074ac5da6', text=response_xml) - single_site = TSC.SiteItem(name='Tableau', content_url='tableau', - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, - user_quota=15, storage_quota=1000, - disable_subscriptions=True, revision_history_enabled=False, - data_acceleration_mode='disable', flow_auto_save_enabled=True, - web_extraction_enabled=False, metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, authoring_enabled=True, - custom_subscription_email_enabled=True, - custom_subscription_email='test@test.com', - custom_subscription_footer_enabled=True, - custom_subscription_footer='example_footer', ask_data_mode='EnabledByDefault', - named_sharing_enabled=False, mobile_biometrics_enabled=True, - sheet_image_enabled=False, derived_permissions_enabled=True, - user_visibility_mode='FULL', use_default_time_zone=False, - time_zone='America/Los_Angeles', auto_suspend_refresh_enabled=True, - auto_suspend_refresh_inactivity_window=55) - single_site._id = '6b7179ba-b82b-4f0f-91ed-812074ac5da6' + m.put(self.baseurl + "/6b7179ba-b82b-4f0f-91ed-812074ac5da6", text=response_xml) + single_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + revision_history_enabled=False, + data_acceleration_mode="disable", + flow_auto_save_enabled=True, + web_extraction_enabled=False, + metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, + authoring_enabled=True, + custom_subscription_email_enabled=True, + custom_subscription_email="test@test.com", + custom_subscription_footer_enabled=True, + custom_subscription_footer="example_footer", + ask_data_mode="EnabledByDefault", + named_sharing_enabled=False, + mobile_biometrics_enabled=True, + sheet_image_enabled=False, + derived_permissions_enabled=True, + user_visibility_mode="FULL", + use_default_time_zone=False, + time_zone="America/Los_Angeles", + auto_suspend_refresh_enabled=True, + auto_suspend_refresh_inactivity_window=55, + tier_creator_capacity=5, + tier_explorer_capacity=5, + tier_viewer_capacity=5, + ) + single_site._id = "6b7179ba-b82b-4f0f-91ed-812074ac5da6" single_site = self.server.sites.update(single_site) - self.assertEqual('6b7179ba-b82b-4f0f-91ed-812074ac5da6', single_site.id) - self.assertEqual('tableau', single_site.content_url) - self.assertEqual('Suspended', single_site.state) - self.assertEqual('Tableau', single_site.name) - self.assertEqual('ContentAndUsers', single_site.admin_mode) + self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", single_site.id) + self.assertEqual("tableau", single_site.content_url) + self.assertEqual("Suspended", single_site.state) + self.assertEqual("Tableau", single_site.name) + self.assertEqual("ContentAndUsers", single_site.admin_mode) self.assertEqual(True, single_site.revision_history_enabled) self.assertEqual(13, single_site.revision_limit) self.assertEqual(True, single_site.disable_subscriptions) - self.assertEqual(15, single_site.user_quota) - self.assertEqual('disable', single_site.data_acceleration_mode) + self.assertEqual(None, single_site.user_quota) + self.assertEqual(5, single_site.tier_creator_capacity) + self.assertEqual(5, single_site.tier_explorer_capacity) + self.assertEqual(5, single_site.tier_viewer_capacity) + self.assertEqual("disable", single_site.data_acceleration_mode) self.assertEqual(True, single_site.flows_enabled) self.assertEqual(True, single_site.cataloging_enabled) self.assertEqual(True, single_site.flow_auto_save_enabled) @@ -146,63 +167,88 @@ def test_update(self): self.assertEqual(False, single_site.notify_site_admins_on_throttle) self.assertEqual(True, single_site.authoring_enabled) self.assertEqual(True, single_site.custom_subscription_email_enabled) - self.assertEqual('test@test.com', single_site.custom_subscription_email) + self.assertEqual("test@test.com", single_site.custom_subscription_email) self.assertEqual(True, single_site.custom_subscription_footer_enabled) - self.assertEqual('example_footer', single_site.custom_subscription_footer) - self.assertEqual('EnabledByDefault', single_site.ask_data_mode) + self.assertEqual("example_footer", single_site.custom_subscription_footer) + self.assertEqual("EnabledByDefault", single_site.ask_data_mode) self.assertEqual(False, single_site.named_sharing_enabled) self.assertEqual(True, single_site.mobile_biometrics_enabled) self.assertEqual(False, single_site.sheet_image_enabled) self.assertEqual(True, single_site.derived_permissions_enabled) - self.assertEqual('FULL', single_site.user_visibility_mode) + self.assertEqual("FULL", single_site.user_visibility_mode) self.assertEqual(False, single_site.use_default_time_zone) - self.assertEqual('America/Los_Angeles', single_site.time_zone) + self.assertEqual("America/Los_Angeles", single_site.time_zone) self.assertEqual(True, single_site.auto_suspend_refresh_enabled) self.assertEqual(55, single_site.auto_suspend_refresh_inactivity_window) - def test_update_missing_id(self): - single_site = TSC.SiteItem('test', 'test') + def test_update_missing_id(self) -> None: + single_site = TSC.SiteItem("test", "test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.sites.update, single_site) - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_null_site_quota(self) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with self.assertRaises(ValueError): + test_site.user_quota = 1 + test_site.tier_explorer_capacity = None + test_site.user_quota = 1 + + def test_replace_license_tiers_with_user_quota(self) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with self.assertRaises(ValueError): + test_site.user_quota = 1 + test_site.replace_license_tiers_with_user_quota(1) + self.assertEqual(1, test_site.user_quota) + self.assertIsNone(test_site.tier_explorer_capacity) + + def test_create(self) -> None: + 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_site = TSC.SiteItem(name='Tableau', content_url='tableau', - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, user_quota=15, - storage_quota=1000, disable_subscriptions=True) + new_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + ) new_site = self.server.sites.create(new_site) - self.assertEqual('0626857c-1def-4503-a7d8-7907c3ff9d9f', new_site.id) - self.assertEqual('tableau', new_site.content_url) - self.assertEqual('Tableau', new_site.name) - self.assertEqual('Active', new_site.state) - self.assertEqual('ContentAndUsers', new_site.admin_mode) + new_site._tier_viewer_capacity = None + new_site._tier_creator_capacity = None + new_site._tier_explorer_capacity = None + self.assertEqual("0626857c-1def-4503-a7d8-7907c3ff9d9f", new_site.id) + self.assertEqual("tableau", new_site.content_url) + self.assertEqual("Tableau", new_site.name) + self.assertEqual("Active", new_site.state) + self.assertEqual("ContentAndUsers", new_site.admin_mode) self.assertEqual(False, new_site.revision_history_enabled) self.assertEqual(True, new_site.subscribe_others_enabled) self.assertEqual(True, new_site.disable_subscriptions) self.assertEqual(15, new_site.user_quota) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f', status_code=204) - self.server.sites.delete('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.delete(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f", status_code=204) + self.server.sites.delete("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.sites.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.sites.delete, "") - def test_encrypt(self): + def test_encrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts', status_code=200) - self.server.sites.encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts", status_code=200) + self.server.sites.encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_recrypt(self): + def test_recrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts', status_code=200) - self.server.sites.re_encrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts", status_code=200) + self.server.sites.re_encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - def test_decrypt(self): + def test_decrypt(self) -> None: with requests_mock.mock() as m: - m.post(self.baseurl + '/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts', status_code=200) - self.server.sites.decrypt_extracts('0626857c-1def-4503-a7d8-7907c3ff9d9f') + m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) + self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") diff --git a/test/test_site_model.py b/test/test_site_model.py index 99fa73ce9..eb086f5af 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,6 +1,7 @@ # coding=utf-8 import unittest + import tableauserverclient as TSC diff --git a/test/test_sort.py b/test/test_sort.py index 0572a1e10..8eebef6f4 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,16 +1,17 @@ -import unittest import re -import requests +import unittest + import requests_mock + import tableauserverclient as TSC class SortTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) self.server.version = "3.10" - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl def test_empty_filter(self): @@ -21,21 +22,17 @@ def test_filter_equals(self): m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.Equals, - 'Superstore')) + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('filter=name%3aeq%3asuperstore', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("filter=name%3aeq%3asuperstore", resp.request.query)) def test_filter_equals_list(self): with self.assertRaises(ValueError) as cm: - TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.Equals, - ['foo', 'bar']) + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["foo", "bar"]) self.assertEqual("Filter values can only be a list if the operator is 'in'.", str(cm.exception)), @@ -45,28 +42,27 @@ def test_filter_in(self): url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, - TSC.RequestOptions.Operator.In, - ['stocks', 'market'])) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('filter=tags%3ain%3a%5bstocks%2cmarket%5d', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) def test_sort_asc(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Direction.Asc)) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search('sort=name%3aasc', resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) def test_filter_combo(self): with requests_mock.mock() as m: @@ -74,25 +70,34 @@ def test_filter_combo(self): url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/users" opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.LastLogin, - TSC.RequestOptions.Operator.GreaterThanOrEqual, - '2017-01-15T00:00:00:00Z')) + opts.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.LastLogin, + TSC.RequestOptions.Operator.GreaterThanOrEqual, + "2017-01-15T00:00:00:00Z", + ) + ) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.SiteRole, - TSC.RequestOptions.Operator.Equals, - 'Publisher')) + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher") + ) resp = self.server.workbooks.get_request(url, request_object=opts) - expected = 'pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a' \ - '2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher' + expected = ( + "pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a" + "2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher" + ) - self.assertTrue(re.search('pagenumber=13', resp.request.query)) - self.assertTrue(re.search('pagesize=13', resp.request.query)) - self.assertTrue(re.search( - 'filter=lastlogin%3agte%3a2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher', - resp.request.query)) + self.assertTrue(re.search("pagenumber=13", resp.request.query)) + self.assertTrue(re.search("pagesize=13", resp.request.query)) + self.assertTrue( + re.search( + "filter=lastlogin%3agte%3a2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher", + resp.request.query, + ) + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/test/test_subscription.py b/test/test_subscription.py index 15b845e56..45dcb0a1c 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -1,6 +1,8 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,9 +13,9 @@ class SubscriptionTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test") - self.server.version = '2.6' + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "2.6" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -21,7 +23,7 @@ def setUp(self): self.baseurl = self.server.subscriptions.baseurl - def test_get_subscriptions(self): + def test_get_subscriptions(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -30,58 +32,59 @@ def test_get_subscriptions(self): self.assertEqual(2, pagination_item.total_available) subscription = all_subscriptions[0] - self.assertEqual('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', subscription.id) - self.assertEqual('NOT FOUND!', subscription.message) + self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) + self.assertEqual("NOT FOUND!", subscription.message) self.assertTrue(subscription.attach_image) self.assertFalse(subscription.attach_pdf) self.assertFalse(subscription.suspended) self.assertFalse(subscription.send_if_view_empty) self.assertIsNone(subscription.page_orientation) self.assertIsNone(subscription.page_size_option) - self.assertEqual('Not Found Alert', subscription.subject) - self.assertEqual('cdd716ca-5818-470e-8bec-086885dbadee', subscription.target.id) - self.assertEqual('View', subscription.target.type) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) + self.assertEqual("Not Found Alert", subscription.subject) + self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) + self.assertEqual("View", subscription.target.type) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) subscription = all_subscriptions[1] - self.assertEqual('23cb7630-afc8-4c8e-b6cd-83ae0322ec66', subscription.id) - self.assertEqual('overview', subscription.message) + self.assertEqual("23cb7630-afc8-4c8e-b6cd-83ae0322ec66", subscription.id) + self.assertEqual("overview", subscription.message) self.assertFalse(subscription.attach_image) self.assertTrue(subscription.attach_pdf) self.assertTrue(subscription.suspended) self.assertTrue(subscription.send_if_view_empty) - self.assertEqual('PORTRAIT', subscription.page_orientation) - self.assertEqual('A5', subscription.page_size_option) - self.assertEqual('Last 7 Days', subscription.subject) - self.assertEqual('2e6b4e8f-22dd-4061-8f75-bf33703da7e5', subscription.target.id) - self.assertEqual('Workbook', subscription.target.type) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('3407cd38-7b39-4983-86a6-67a1506a5e3f', subscription.schedule_id) - - def test_get_subscription_by_id(self): + self.assertEqual("PORTRAIT", subscription.page_orientation) + self.assertEqual("A5", subscription.page_size_option) + self.assertEqual("Last 7 Days", subscription.subject) + self.assertEqual("2e6b4e8f-22dd-4061-8f75-bf33703da7e5", subscription.target.id) + self.assertEqual("Workbook", subscription.target.type) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("3407cd38-7b39-4983-86a6-67a1506a5e3f", subscription.schedule_id) + + def test_get_subscription_by_id(self) -> None: with open(GET_XML_BY_ID, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', text=response_xml) - subscription = self.server.subscriptions.get_by_id('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4') - - self.assertEqual('382e9a6e-0c08-4a95-b6c1-c14df7bac3e4', subscription.id) - self.assertEqual('View', subscription.target.type) - self.assertEqual('cdd716ca-5818-470e-8bec-086885dbadee', subscription.target.id) - self.assertEqual('c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e', subscription.user_id) - self.assertEqual('Not Found Alert', subscription.subject) - self.assertEqual('7617c389-cdca-4940-a66e-69956fcebf3e', subscription.schedule_id) - - def test_create_subscription(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + m.get(self.baseurl + "/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", text=response_xml) + subscription = self.server.subscriptions.get_by_id("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4") + + self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) + self.assertEqual("View", subscription.target.type) + self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) + self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) + self.assertEqual("Not Found Alert", subscription.subject) + self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) + + def test_create_subscription(self) -> None: + 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) target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") - new_subscription = TSC.SubscriptionItem("subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", - "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item) + new_subscription = TSC.SubscriptionItem( + "subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item + ) new_subscription = self.server.subscriptions.create(new_subscription) self.assertEqual("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", new_subscription.id) @@ -91,7 +94,7 @@ def test_create_subscription(self): self.assertEqual("4906c453-d5ec-4972-9ff4-789b629bdfa2", new_subscription.schedule_id) self.assertEqual("8d30c8de-0a5f-4bee-b266-c621b4f3eed0", new_subscription.user_id) - def test_delete_subscription(self): + def test_delete_subscription(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc', status_code=204) - self.server.subscriptions.delete('78e9318d-2d29-4d67-b60f-3f2f5fd89ecc') + m.delete(self.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) + self.server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") diff --git a/test/test_table.py b/test/test_table.py index 45af43c9a..8c6c71f76 100644 --- a/test/test_table.py +++ b/test/test_table.py @@ -1,24 +1,21 @@ import unittest -import os + import requests_mock -import xml.etree.ElementTree as ET + import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset +from ._utils import read_xml_asset -GET_XML = 'table_get.xml' -UPDATE_XML = 'table_update.xml' +GET_XML = "table_get.xml" +UPDATE_XML = "table_update.xml" class TableTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.server.version = "3.5" self.baseurl = self.server.tables.baseurl @@ -30,33 +27,33 @@ def test_get(self): all_tables, pagination_item = self.server.tables.get() self.assertEqual(4, pagination_item.total_available) - self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', all_tables[0].id) - self.assertEqual('dim_Product', all_tables[0].name) + self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", all_tables[0].id) + self.assertEqual("dim_Product", all_tables[0].name) - self.assertEqual('53c77bc1-fb41-4342-a75a-f68ac0656d0d', all_tables[1].id) - self.assertEqual('customer', all_tables[1].name) - self.assertEqual('dbo', all_tables[1].schema) - self.assertEqual('9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0', all_tables[1].contact_id) + self.assertEqual("53c77bc1-fb41-4342-a75a-f68ac0656d0d", all_tables[1].id) + self.assertEqual("customer", all_tables[1].name) + self.assertEqual("dbo", all_tables[1].schema) + self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_tables[1].contact_id) self.assertEqual(False, all_tables[1].certified) def test_update(self): response_xml = read_xml_asset(UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + '/10224773-ecee-42ac-b822-d786b0b8e4d9', text=response_xml) - single_table = TSC.TableItem('test') - single_table._id = '10224773-ecee-42ac-b822-d786b0b8e4d9' + m.put(self.baseurl + "/10224773-ecee-42ac-b822-d786b0b8e4d9", text=response_xml) + single_table = TSC.TableItem("test") + single_table._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" - single_table.contact_id = '8e1a8235-c9ee-4d61-ae82-2ffacceed8e0' + single_table.contact_id = "8e1a8235-c9ee-4d61-ae82-2ffacceed8e0" single_table.certified = True single_table.certification_note = "Test" single_table = self.server.tables.update(single_table) - self.assertEqual('10224773-ecee-42ac-b822-d786b0b8e4d9', single_table.id) - self.assertEqual('8e1a8235-c9ee-4d61-ae82-2ffacceed8e0', single_table.contact_id) + self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", single_table.id) + self.assertEqual("8e1a8235-c9ee-4d61-ae82-2ffacceed8e0", single_table.contact_id) self.assertEqual(True, single_table.certified) self.assertEqual("Test", single_table.certification_note) def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/0448d2ed-590d-4fa0-b272-a2a8a24555b5', status_code=204) - self.server.tables.delete('0448d2ed-590d-4fa0-b272-a2a8a24555b5') + m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + self.server.tables.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index 94a44706a..e8ae242d9 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,14 +1,12 @@ import unittest import warnings + import tableauserverclient as TSC class TableauAuthModelTests(unittest.TestCase): def setUp(self): - self.auth = TSC.TableauAuth('user', - 'password', - site_id='site1', - user_id_to_impersonate='admin') + self.auth = TSC.TableauAuth("user", "password", site_id="site1", user_id_to_impersonate="admin") def test_username_password_required(self): with self.assertRaises(TypeError): @@ -18,8 +16,6 @@ def test_site_arg_raises_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - tableau_auth = TSC.TableauAuth('user', - 'password', - site='Default') + tableau_auth = TSC.TableauAuth("user", "password", site="Default") self.assertTrue(any(item.category == DeprecationWarning for item in w)) diff --git a/test/test_task.py b/test/test_task.py index 566167d4a..5c432208d 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,9 +1,11 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC -from tableauserverclient.models.task_item import TaskItem from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.task_item import TaskItem TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -17,8 +19,8 @@ class TaskTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server("http://test") - self.server.version = '3.8' + self.server = TSC.Server("http://test", False) + self.server.version = "3.8" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -45,8 +47,8 @@ def test_get_tasks_with_workbook(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) def test_get_tasks_with_datasource(self): with open(GET_XML_WITH_DATASOURCE, "rb") as f: @@ -56,8 +58,8 @@ def test_get_tasks_with_datasource(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('datasource', task.target.type) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("datasource", task.target.type) def test_get_tasks_with_workbook_and_datasource(self): with open(GET_XML_WITH_WORKBOOK_AND_DATASOURCE, "rb") as f: @@ -66,9 +68,9 @@ def test_get_tasks_with_workbook_and_datasource(self): m.get(self.baseurl, text=response_xml) all_tasks, pagination_item = self.server.tasks.get() - self.assertEqual('workbook', all_tasks[0].target.type) - self.assertEqual('datasource', all_tasks[1].target.type) - self.assertEqual('workbook', all_tasks[2].target.type) + self.assertEqual("workbook", all_tasks[0].target.type) + self.assertEqual("datasource", all_tasks[1].target.type) + self.assertEqual("workbook", all_tasks[2].target.type) def test_get_task_with_schedule(self): with open(GET_XML_WITH_WORKBOOK, "rb") as f: @@ -78,63 +80,64 @@ def test_get_task_with_schedule(self): all_tasks, pagination_item = self.server.tasks.get() task = all_tasks[0] - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) def test_delete(self): with requests_mock.mock() as m: - m.delete(self.baseurl + '/c7a9327e-1cda-4504-b026-ddb43b976d1d', status_code=204) - self.server.tasks.delete('c7a9327e-1cda-4504-b026-ddb43b976d1d') + m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) + self.server.tasks.delete("c7a9327e-1cda-4504-b026-ddb43b976d1d") def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.tasks.delete, '') + self.assertRaises(ValueError, self.server.tasks.delete, "") def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] - self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) - self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) - self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + self.assertEqual("a462c148-fc40-4670-a8e4-39b7f0c58c7f", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b22190b4-6ac2-4eed-9563-4afc03444413", task.schedule_id) + self.assertEqual(parse_datetime("2019-12-09T22:30:00Z"), task.schedule_item.next_run_at) + self.assertEqual(parse_datetime("2019-12-09T20:45:04Z"), task.last_run_at) self.assertEqual(TSC.TaskItem.Type.DataAcceleration, task.task_type) def test_delete_data_acceleration(self): with requests_mock.mock() as m: - m.delete('{}/{}/{}'.format( - self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, - 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) - self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', - TaskItem.Type.DataAcceleration) + m.delete( + "{}/{}/{}".format( + self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + ), + status_code=204, + ) + self.server.tasks.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", TaskItem.Type.DataAcceleration) def test_get_by_id(self): with open(GET_XML_WITH_WORKBOOK, "rb") as f: response_xml = f.read().decode("utf-8") - task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml) + m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) task = self.server.tasks.get_by_id(task_id) - self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) - self.assertEqual('workbook', task.target.type) - self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("workbook", task.target.type) + self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) self.assertEqual(TSC.TaskItem.Type.ExtractRefresh, task.task_type) def test_run_now(self): - task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml) + m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") - self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content) - self.assertTrue('RefreshExtract' in job_response_content) + self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) + self.assertTrue("RefreshExtract" in job_response_content) diff --git a/test/test_user.py b/test/test_user.py index e4d1d6717..6ba8ff7f2 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,34 +1,36 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -GET_XML = os.path.join(TEST_ASSET_DIR, 'user_get.xml') -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'user_get_empty.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'user_get_by_id.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'user_update.xml') -ADD_XML = os.path.join(TEST_ASSET_DIR, 'user_add.xml') -POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_workbooks.xml') -GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, 'favorites_get.xml') -POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, 'user_populate_groups.xml') +GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") +ADD_XML = os.path.join(TEST_ASSET_DIR, "user_add.xml") +POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_workbooks.xml") +GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") +POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") class UserTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.users.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?fields=_all_", text=response_xml) all_users, pagination_item = self.server.users.get() @@ -36,24 +38,24 @@ def test_get(self): self.assertEqual(2, pagination_item.total_available) self.assertEqual(2, len(all_users)) - self.assertTrue(any(user.id == 'dd2239f6-ddf1-4107-981a-4cf94e415794' for user in all_users)) - single_user = next(user for user in all_users if user.id == 'dd2239f6-ddf1-4107-981a-4cf94e415794') - self.assertEqual('alice', single_user.name) - self.assertEqual('Publisher', single_user.site_role) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) - self.assertEqual('alice cook', single_user.fullname) - self.assertEqual('alicecook@test.com', single_user.email) - - self.assertTrue(any(user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3' for user in all_users)) - single_user = next(user for user in all_users if user.id == '2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3') - self.assertEqual('Bob', single_user.name) - self.assertEqual('Interactor', single_user.site_role) - self.assertEqual('Bob Smith', single_user.fullname) - self.assertEqual('bob@test.com', single_user.email) - - def test_get_empty(self): - with open(GET_EMPTY_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertTrue(any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users)) + single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794") + self.assertEqual("alice", single_user.name) + self.assertEqual("Publisher", single_user.site_role) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) + self.assertEqual("alice cook", single_user.fullname) + self.assertEqual("alicecook@test.com", single_user.email) + + self.assertTrue(any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users)) + single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3") + self.assertEqual("Bob", single_user.name) + self.assertEqual("Interactor", single_user.site_role) + self.assertEqual("Bob Smith", single_user.fullname) + self.assertEqual("bob@test.com", single_user.email) + + def test_get_empty(self) -> None: + with open(GET_EMPTY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_users, pagination_item = self.server.users.get() @@ -61,144 +63,142 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_users) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.users.get) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', text=response_xml) - single_user = self.server.users.get_by_id('dd2239f6-ddf1-4107-981a-4cf94e415794') - - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_user.id) - self.assertEqual('alice', single_user.name) - self.assertEqual('Alice', single_user.fullname) - self.assertEqual('Publisher', single_user.site_role) - self.assertEqual('ServerDefault', single_user.auth_setting) - self.assertEqual('2016-08-16T23:17:06Z', format_datetime(single_user.last_login)) - self.assertEqual('local', single_user.domain_name) - - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.users.get_by_id, '') - - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = self.server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794") + + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_user.id) + self.assertEqual("alice", single_user.name) + self.assertEqual("Alice", single_user.fullname) + self.assertEqual("Publisher", single_user.site_role) + self.assertEqual("ServerDefault", single_user.auth_setting) + self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) + self.assertEqual("local", single_user.domain_name) + + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.users.get_by_id, "") + + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', text=response_xml) - single_user = TSC.UserItem('test', 'Viewer') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_user.name = 'Cassie' - single_user.fullname = 'Cassie' - single_user.email = 'cassie@email.com' + m.put(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = TSC.UserItem("test", "Viewer") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_user.name = "Cassie" + single_user.fullname = "Cassie" + single_user.email = "cassie@email.com" single_user = self.server.users.update(single_user) - self.assertEqual('Cassie', single_user.name) - self.assertEqual('Cassie', single_user.fullname) - self.assertEqual('cassie@email.com', single_user.email) - self.assertEqual('Viewer', single_user.site_role) + self.assertEqual("Cassie", single_user.name) + self.assertEqual("Cassie", single_user.fullname) + self.assertEqual("cassie@email.com", single_user.email) + self.assertEqual("Viewer", single_user.site_role) - def test_update_missing_id(self): - single_user = TSC.UserItem('test', 'Interactor') + def test_update_missing_id(self) -> None: + single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.update, single_user) - def test_remove(self): + def test_remove(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794', status_code=204) - self.server.users.remove('dd2239f6-ddf1-4107-981a-4cf94e415794') + m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) + self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") - def test_remove_missing_id(self): - self.assertRaises(ValueError, self.server.users.remove, '') + def test_remove_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.users.remove, "") - def test_add(self): - with open(ADD_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_add(self) -> None: + with open(ADD_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_user = TSC.UserItem(name='Cassie', site_role='Viewer', auth_setting='ServerDefault') + m.post(self.baseurl + "", text=response_xml) + new_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") new_user = self.server.users.add(new_user) - self.assertEqual('4cc4c17f-898a-4de4-abed-a1681c673ced', new_user.id) - self.assertEqual('Cassie', new_user.name) - self.assertEqual('Viewer', new_user.site_role) - self.assertEqual('ServerDefault', new_user.auth_setting) + self.assertEqual("4cc4c17f-898a-4de4-abed-a1681c673ced", new_user.id) + self.assertEqual("Cassie", new_user.name) + self.assertEqual("Viewer", new_user.site_role) + self.assertEqual("ServerDefault", new_user.auth_setting) - def test_populate_workbooks(self): - with open(POPULATE_WORKBOOKS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks', - text=response_xml) - single_user = TSC.UserItem('test', 'Interactor') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" self.server.users.populate_workbooks(single_user) workbook_list = list(single_user.workbooks) - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', workbook_list[0].id) - self.assertEqual('SafariSample', workbook_list[0].name) - self.assertEqual('SafariSample', workbook_list[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", workbook_list[0].id) + self.assertEqual("SafariSample", workbook_list[0].name) + self.assertEqual("SafariSample", workbook_list[0].content_url) self.assertEqual(False, workbook_list[0].show_tabs) self.assertEqual(26, workbook_list[0].size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(workbook_list[0].created_at)) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(workbook_list[0].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', workbook_list[0].project_id) - self.assertEqual('default', workbook_list[0].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', workbook_list[0].owner_id) - self.assertEqual(set(['Safari', 'Sample']), workbook_list[0].tags) - - def test_populate_workbooks_missing_id(self): - single_user = TSC.UserItem('test', 'Interactor') + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(workbook_list[0].created_at)) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(workbook_list[0].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) + self.assertEqual("default", workbook_list[0].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) + self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + + def test_populate_workbooks_missing_id(self) -> None: + single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) - def test_populate_favorites(self): - self.server.version = '2.5' + def test_populate_favorites(self) -> None: + self.server.version = "2.5" baseurl = self.server.favorites.baseurl - single_user = TSC.UserItem('test', 'Interactor') - with open(GET_FAVORITES_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + single_user = TSC.UserItem("test", "Interactor") + with open(GET_FAVORITES_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get('{0}/{1}'.format(baseurl, single_user.id), text=response_xml) + m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) - self.assertEqual(len(single_user.favorites['workbooks']), 1) - self.assertEqual(len(single_user.favorites['views']), 1) - self.assertEqual(len(single_user.favorites['projects']), 1) - self.assertEqual(len(single_user.favorites['datasources']), 1) - - workbook = single_user.favorites['workbooks'][0] - view = single_user.favorites['views'][0] - datasource = single_user.favorites['datasources'][0] - project = single_user.favorites['projects'][0] - - self.assertEqual(workbook.id, '6d13b0ca-043d-4d42-8c9d-3f3313ea3a00') - self.assertEqual(view.id, 'd79634e1-6063-4ec9-95ff-50acbf609ff5') - self.assertEqual(datasource.id, 'e76a1461-3b1d-4588-bf1b-17551a879ad9') - self.assertEqual(project.id, '1d0304cd-3796-429f-b815-7258370b9b74') - - def test_populate_groups(self): - self.server.version = '3.7' - with open(POPULATE_GROUPS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(len(single_user.favorites["workbooks"]), 1) + self.assertEqual(len(single_user.favorites["views"]), 1) + self.assertEqual(len(single_user.favorites["projects"]), 1) + self.assertEqual(len(single_user.favorites["datasources"]), 1) + + workbook = single_user.favorites["workbooks"][0] + view = single_user.favorites["views"][0] + datasource = single_user.favorites["datasources"][0] + project = single_user.favorites["projects"][0] + + self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") + self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") + self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") + self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + + def test_populate_groups(self) -> None: + self.server.version = "3.7" + with open(POPULATE_GROUPS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.users.baseurl + '/dd2239f6-ddf1-4107-981a-4cf94e415794/groups', - text=response_xml) - single_user = TSC.UserItem('test', 'Interactor') - single_user._id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' + m.get(self.server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" self.server.users.populate_groups(single_user) group_list = list(single_user.groups) self.assertEqual(3, len(group_list)) - self.assertEqual('ef8b19c0-43b6-11e6-af50-63f5805dbe3c', group_list[0].id) - self.assertEqual('All Users', group_list[0].name) - self.assertEqual('local', group_list[0].domain_name) + self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group_list[0].id) + self.assertEqual("All Users", group_list[0].name) + self.assertEqual("local", group_list[0].domain_name) - self.assertEqual('e7833b48-c6f7-47b5-a2a7-36e7dd232758', group_list[1].id) - self.assertEqual('Another group', group_list[1].name) - self.assertEqual('local', group_list[1].domain_name) + self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", group_list[1].id) + self.assertEqual("Another group", group_list[1].name) + self.assertEqual("local", group_list[1].domain_name) - self.assertEqual('86a66d40-f289-472a-83d0-927b0f954dc8', group_list[2].id) - self.assertEqual('TableauExample', group_list[2].name) - self.assertEqual('local', group_list[2].domain_name) + self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) + self.assertEqual("TableauExample", group_list[2].name) + self.assertEqual("local", group_list[2].domain_name) diff --git a/test/test_user_model.py b/test/test_user_model.py index 5826fb148..ba70b1c7c 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC diff --git a/test/test_view.py b/test/test_view.py index e32971ea2..3562650d1 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -1,114 +1,117 @@ -import unittest import os +import unittest + import requests_mock -import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule +from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'view_add_tags.xml') -GET_XML = os.path.join(TEST_ASSET_DIR, 'view_get.xml') -GET_XML_ID = os.path.join(TEST_ASSET_DIR, 'view_get_id.xml') -GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, 'view_get_usage.xml') -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'Sample View Image.png') -POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') -POPULATE_CSV = os.path.join(TEST_ASSET_DIR, 'populate_csv.csv') -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'view_populate_permissions.xml') -UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'view_update_permissions.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') +ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") +GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") +GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") +POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") +POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") +POPULATE_EXCEL = os.path.join(TEST_ASSET_DIR, "populate_excel.xlsx") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "view_populate_permissions.xml") +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "view_update_permissions.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") class ViewTests(unittest.TestCase): def setUp(self): - self.server = TSC.Server('http://test') - self.server.version = '3.2' + self.server = TSC.Server("http://test", False) + self.server.version = "3.2" # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.views.baseurl self.siteurl = self.server.views.siteurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + 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) all_views, pagination_item = self.server.views.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id) - self.assertEqual('ENDANGERED SAFARI', all_views[0].name) - self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', all_views[0].content_url) - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_views[0].workbook_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[0].owner_id) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', all_views[0].project_id) - self.assertEqual(set(['tag1', 'tag2']), all_views[0].tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) + self.assertEqual("ENDANGERED SAFARI", all_views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) + self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) - self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) - self.assertEqual('Overview', all_views[1].name) - self.assertEqual('Superstore/sheets/Overview', all_views[1].content_url) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_views[1].workbook_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_views[1].owner_id) - self.assertEqual('5b534f74-3226-11e8-b47a-cb2e00f738a3', all_views[1].project_id) - self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) - self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) - self.assertEqual('story', all_views[1].sheet_type) - - def test_get_by_id(self): - with open(GET_XML_ID, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) + self.assertEqual("Overview", all_views[1].name) + self.assertEqual("Superstore/sheets/Overview", all_views[1].content_url) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner_id) + self.assertEqual("5b534f74-3226-11e8-b47a-cb2e00f738a3", all_views[1].project_id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertEqual("story", all_views[1].sheet_type) + + def test_get_by_id(self) -> None: + with open(GET_XML_ID, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5', text=response_xml) - view = self.server.views.get_by_id('d79634e1-6063-4ec9-95ff-50acbf609ff5') - - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', view.id) - self.assertEqual('ENDANGERED SAFARI', view.name) - self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', view.content_url) - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', view.workbook_id) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', view.owner_id) - self.assertEqual('5241e88d-d384-4fd7-9c2f-648b5247efc5', view.project_id) - self.assertEqual(set(['tag1', 'tag2']), view.tags) - self.assertEqual('2002-05-30T09:00:00Z', format_datetime(view.created_at)) - self.assertEqual('2002-06-05T08:00:59Z', format_datetime(view.updated_at)) - self.assertEqual('story', view.sheet_type) - - def test_get_by_id_missing_id(self): + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) + self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + self.assertEqual("story", view.sheet_type) + + def test_get_by_id_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) - def test_get_with_usage(self): - with open(GET_XML_USAGE, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_with_usage(self) -> None: + with open(GET_XML_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?includeUsageStatistics=true", text=response_xml) all_views, pagination_item = self.server.views.get(usage=True) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', all_views[0].id) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) self.assertEqual(7, all_views[0].total_views) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) - self.assertEqual('fd252f73-593c-4c4e-8584-c032b8022adc', all_views[1].id) + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual(13, all_views[1].total_views) - self.assertEqual('2002-05-30T09:00:00Z', format_datetime(all_views[1].created_at)) - self.assertEqual('2002-06-05T08:00:59Z', format_datetime(all_views[1].updated_at)) - self.assertEqual('story', all_views[1].sheet_type) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertEqual("story", all_views[1].sheet_type) - def test_get_with_usage_and_filter(self): - with open(GET_XML_USAGE, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_with_usage_and_filter(self) -> None: + with open(GET_XML_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "?includeUsageStatistics=true&filter=name:in:[foo,bar]", text=response_xml) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["foo", "bar"])) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["foo", "bar"]) + ) all_views, pagination_item = self.server.views.get(req_options=options, usage=True) self.assertEqual("ENDANGERED SAFARI", all_views[0].name) @@ -116,61 +119,67 @@ def test_get_with_usage_and_filter(self): self.assertEqual("Overview", all_views[1].name) self.assertEqual(13, all_views[1].total_views) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.views.get) - def test_populate_preview_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_preview_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.siteurl + '/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/' - 'views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage', content=response) + m.get( + self.siteurl + "/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/" + "views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage", + content=response, + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - single_view._workbook_id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" self.server.views.populate_preview_image(single_view) self.assertEqual(response, single_view.preview_image) - def test_populate_preview_image_missing_id(self): + def test_populate_preview_image_missing_id(self) -> None: single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) single_view._id = None - single_view._workbook_id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' + single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) - def test_populate_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image', content=response) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) - def test_populate_image_with_options(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_image_with_options(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10', - content=response) + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) self.server.views.populate_image(single_view, req_option) self.assertEqual(response, single_view.image) - def test_populate_pdf(self): - with open(POPULATE_PDF, 'rb') as f: + def test_populate_pdf(self) -> None: + with open(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) + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" size = TSC.PDFRequestOptions.PageType.Letter orientation = TSC.PDFRequestOptions.Orientation.Portrait @@ -179,39 +188,39 @@ def test_populate_pdf(self): self.server.views.populate_pdf(single_view, req_option) self.assertEqual(response, single_view.pdf) - def test_populate_csv(self): - with open(POPULATE_CSV, 'rb') as f: + def test_populate_csv(self) -> None: + with open(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) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" request_option = TSC.CSVRequestOptions(maxage=1) self.server.views.populate_csv(single_view, request_option) csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) - def test_populate_csv_default_maxage(self): - with open(POPULATE_CSV, 'rb') as f: + def test_populate_csv_default_maxage(self) -> None: + with open(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) + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" self.server.views.populate_csv(single_view) csv_file = b"".join(single_view.csv) self.assertEqual(response, csv_file) - def test_populate_image_missing_id(self): + def test_populate_image_missing_id(self) -> None: single_view = TSC.ViewItem() single_view._id = None self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_image, single_view) - def test_populate_permissions(self): - with open(POPULATE_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(POPULATE_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl + "/e490bec4-2652-4fda-8c4e-f087db6fa328/permissions", text=response_xml) single_view = TSC.ViewItem() @@ -220,63 +229,73 @@ def test_populate_permissions(self): self.server.views.populate_permissions(single_view) permissions = single_view.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, 'c8f2773a-c83a-11e8-8c8f-33e6d787b506') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - - }) - - def test_add_permissions(self): - with open(UPDATE_PERMISSIONS, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + }, + ) + + def test_add_permissions(self) -> None: + with open(UPDATE_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") single_view = TSC.ViewItem() - single_view._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + single_view._id = "21778de4-b7b9-44bc-a599-1506a2639ace" bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") - new_permissions = [ - PermissionsRule(bob, {'Write': 'Allow'}), - PermissionsRule(group_of_people, {'Read': 'Deny'}) - ] + new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] with requests_mock.mock() as m: m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) permissions = self.server.views.update_permissions(single_view, new_permissions) - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow - }) - - def test_update_tags(self): - with open(ADD_TAGS_XML, 'rb') as f: - add_tags_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) + + def test_update_tags(self) -> None: + with open(ADD_TAGS_XML, "rb") as f: + add_tags_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags', text=add_tags_xml) - m.delete(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b', status_code=204) - m.delete(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d', status_code=204) - m.put(self.baseurl + '/d79634e1-6063-4ec9-95ff-50acbf609ff5', text=update_xml) + m.put(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags", text=add_tags_xml) + m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b", status_code=204) + m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d", status_code=204) + m.put(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=update_xml) single_view = TSC.ViewItem() - single_view._id = 'd79634e1-6063-4ec9-95ff-50acbf609ff5' - single_view._initial_tags.update(['a', 'b', 'c', 'd']) - single_view.tags.update(['a', 'c', 'e']) + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + single_view._initial_tags.update(["a", "b", "c", "d"]) + single_view.tags.update(["a", "c", "e"]) updated_view = self.server.views.update(single_view) self.assertEqual(single_view.tags, updated_view.tags) self.assertEqual(single_view._initial_tags, updated_view._initial_tags) + + def test_populate_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) diff --git a/test/test_webhook.py b/test/test_webhook.py index 819de18ae..ff8b7048e 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -1,32 +1,33 @@ -import unittest import os +import unittest + import requests_mock + import tableauserverclient as TSC from tableauserverclient.server import RequestFactory, WebhookItem +from ._utils import asset -from ._utils import read_xml_asset, read_xml_assets, asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') +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') +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') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) self.server.version = "3.6" # Fake signin - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + 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') + def test_get(self) -> None: + 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() @@ -39,26 +40,26 @@ def test_get(self): self.assertEqual(webhook.name, "webhook-name") self.assertEqual(webhook.id, "webhook-id") - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.webhooks.get) - def test_delete(self): + def test_delete(self) -> None: 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') + 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_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.webhooks.delete, "") - def test_test(self): + def test_test(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test', status_code=200) - self.server.webhooks.test('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + m.get(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test", status_code=200) + self.server.webhooks.test("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - def test_create(self): - with open(CREATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_create(self) -> None: + 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) webhook_model = TSC.WebhookItem() @@ -71,13 +72,12 @@ def test_create(self): 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') + 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')) + 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 # windows does /r/n for linebreaks, remove the extra char if it is there - self.assertEqual(webhook_request_expected.replace('\r', ''), webhook_request_actual) + self.assertEqual(webhook_request_expected.replace("\r", ""), webhook_request_actual) diff --git a/test/test_workbook.py b/test/test_workbook.py index 459b1f905..db7f0723b 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,103 +1,108 @@ -import unittest -from io import BytesIO import os import re import requests_mock import tableauserverclient as TSC +import tempfile +import unittest import xml.etree.ElementTree as ET +from defusedxml.ElementTree import fromstring +from io import BytesIO +from pathlib import Path +import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import InternalServerError -from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.models.group_item import GroupItem from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models.user_item import UserItem -from tableauserverclient.models.group_item import GroupItem - +from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') - -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_add_tags.xml') -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id.xml') -GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, 'workbook_get_by_id_personal.xml') -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_empty.xml') -GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get_invalid_date.xml') -GET_XML = os.path.join(TEST_ASSET_DIR, 'workbook_get.xml') -POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_connections.xml') -POPULATE_PDF = os.path.join(TEST_ASSET_DIR, 'populate_pdf.pdf') -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_permissions.xml') -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, 'RESTAPISample Image.png') -POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views.xml') -POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_populate_views_usage.xml') -PUBLISH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish.xml') -PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, 'workbook_publish_async.xml') -REFRESH_XML = os.path.join(TEST_ASSET_DIR, 'workbook_refresh.xml') -UPDATE_XML = os.path.join(TEST_ASSET_DIR, 'workbook_update.xml') -UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, 'workbook_update_permissions.xml') +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") +GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") +GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") +GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") +GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") +GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") +POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +POPULATE_POWERPOINT = os.path.join(TEST_ASSET_DIR, "populate_powerpoint.pptx") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_permissions.xml") +POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "RESTAPISample Image.png") +POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") +POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views_usage.xml") +PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish.xml") +PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish_async.xml") +REFRESH_XML = os.path.join(TEST_ASSET_DIR, "workbook_refresh.xml") +REVISION_XML = os.path.join(TEST_ASSET_DIR, "workbook_revision.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") +UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "workbook_update_permissions.xml") class WorkbookTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server('http://test') + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) # Fake sign in - self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' - self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" self.baseurl = self.server.workbooks.baseurl - def test_get(self): - with open(GET_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get(self) -> None: + 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) all_workbooks, pagination_item = self.server.workbooks.get() self.assertEqual(2, pagination_item.total_available) - self.assertEqual('6d13b0ca-043d-4d42-8c9d-3f3313ea3a00', all_workbooks[0].id) - self.assertEqual('Superstore', all_workbooks[0].name) - self.assertEqual('Superstore', all_workbooks[0].content_url) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_workbooks[0].id) + self.assertEqual("Superstore", all_workbooks[0].name) + self.assertEqual("Superstore", all_workbooks[0].content_url) self.assertEqual(False, all_workbooks[0].show_tabs) - self.assertEqual('http://tableauserver/#/workbooks/1/views', all_workbooks[0].webpage_url) + self.assertEqual("http://tableauserver/#/workbooks/1/views", all_workbooks[0].webpage_url) self.assertEqual(1, all_workbooks[0].size) - self.assertEqual('2016-08-03T20:34:04Z', format_datetime(all_workbooks[0].created_at)) - self.assertEqual('description for Superstore', all_workbooks[0].description) - self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[0].project_id) - self.assertEqual('default', all_workbooks[0].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[0].owner_id) - - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', all_workbooks[1].id) - self.assertEqual('SafariSample', all_workbooks[1].name) - self.assertEqual('SafariSample', all_workbooks[1].content_url) - self.assertEqual('http://tableauserver/#/workbooks/2/views', all_workbooks[1].webpage_url) + self.assertEqual("2016-08-03T20:34:04Z", format_datetime(all_workbooks[0].created_at)) + self.assertEqual("description for Superstore", all_workbooks[0].description) + self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[0].project_id) + self.assertEqual("default", all_workbooks[0].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[0].owner_id) + + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_workbooks[1].id) + self.assertEqual("SafariSample", all_workbooks[1].name) + self.assertEqual("SafariSample", all_workbooks[1].content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", all_workbooks[1].webpage_url) self.assertEqual(False, all_workbooks[1].show_tabs) self.assertEqual(26, all_workbooks[1].size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(all_workbooks[1].created_at)) - self.assertEqual('description for SafariSample', all_workbooks[1].description) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(all_workbooks[1].updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', all_workbooks[1].project_id) - self.assertEqual('default', all_workbooks[1].project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', all_workbooks[1].owner_id) - self.assertEqual(set(['Safari', 'Sample']), all_workbooks[1].tags) - - def test_get_ignore_invalid_date(self): - with open(GET_INVALID_DATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(all_workbooks[1].created_at)) + self.assertEqual("description for SafariSample", all_workbooks[1].description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(all_workbooks[1].updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) + self.assertEqual("default", all_workbooks[1].project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) + self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + + def test_get_ignore_invalid_date(self) -> None: + with open(GET_INVALID_DATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_workbooks, pagination_item = self.server.workbooks.get() self.assertEqual(None, format_datetime(all_workbooks[0].created_at)) - self.assertEqual('2016-08-04T17:56:41Z', format_datetime(all_workbooks[0].updated_at)) + self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) - def test_get_before_signin(self): + def test_get_before_signin(self) -> None: self.server._auth_token = None self.assertRaises(TSC.NotSignedInError, self.server.workbooks.get) - def test_get_empty(self): - with open(GET_EMPTY_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_empty(self) -> None: + with open(GET_EMPTY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) all_workbooks, pagination_item = self.server.workbooks.get() @@ -105,125 +110,125 @@ def test_get_empty(self): self.assertEqual(0, pagination_item.total_available) self.assertEqual([], all_workbooks) - def test_get_by_id(self): - with open(GET_BY_ID_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_get_by_id(self) -> None: + with open(GET_BY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42', text=response_xml) - single_workbook = self.server.workbooks.get_by_id('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d42', single_workbook.id) - self.assertEqual('SafariSample', single_workbook.name) - self.assertEqual('SafariSample', single_workbook.content_url) - self.assertEqual('http://tableauserver/#/workbooks/2/views', single_workbook.webpage_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id) + self.assertEqual("SafariSample", single_workbook.name) + self.assertEqual("SafariSample", single_workbook.content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) - self.assertEqual('description for SafariSample', single_workbook.description) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', single_workbook.project_id) - self.assertEqual('default', single_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_workbook.owner_id) - self.assertEqual(set(['Safari', 'Sample']), single_workbook.tags) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_workbook.views[0].id) - self.assertEqual('ENDANGERED SAFARI', single_workbook.views[0].name) - self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', single_workbook.views[0].content_url) - - def test_get_by_id_personal(self): + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) + self.assertEqual("description for SafariSample", single_workbook.description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) + self.assertEqual("default", single_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) + self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + + def test_get_by_id_personal(self) -> None: # workbooks in personal space don't have project_id or project_name - with open(GET_BY_ID_XML_PERSONAL, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(GET_BY_ID_XML_PERSONAL, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d43', text=response_xml) - single_workbook = self.server.workbooks.get_by_id('3cc6cd06-89ce-4fdc-b935-5294135d6d43') + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d43", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d43") - self.assertEqual('3cc6cd06-89ce-4fdc-b935-5294135d6d43', single_workbook.id) - self.assertEqual('SafariSample', single_workbook.name) - self.assertEqual('SafariSample', single_workbook.content_url) - self.assertEqual('http://tableauserver/#/workbooks/2/views', single_workbook.webpage_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d43", single_workbook.id) + self.assertEqual("SafariSample", single_workbook.name) + self.assertEqual("SafariSample", single_workbook.content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) self.assertEqual(False, single_workbook.show_tabs) self.assertEqual(26, single_workbook.size) - self.assertEqual('2016-07-26T20:34:56Z', format_datetime(single_workbook.created_at)) - self.assertEqual('description for SafariSample', single_workbook.description) - self.assertEqual('2016-07-26T20:35:05Z', format_datetime(single_workbook.updated_at)) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) + self.assertEqual("description for SafariSample", single_workbook.description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', single_workbook.owner_id) - self.assertEqual(set(['Safari', 'Sample']), single_workbook.tags) - self.assertEqual('d79634e1-6063-4ec9-95ff-50acbf609ff5', single_workbook.views[0].id) - self.assertEqual('ENDANGERED SAFARI', single_workbook.views[0].name) - self.assertEqual('SafariSample/sheets/ENDANGEREDSAFARI', single_workbook.views[0].content_url) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) + self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) - def test_get_by_id_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.get_by_id, '') + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.get_by_id, "") - def test_refresh_id(self): - self.server.version = '2.8' + def test_refresh_id(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.workbooks.baseurl - with open(REFRESH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + with open(REFRESH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', - status_code=202, text=response_xml) - self.server.workbooks.refresh('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) + self.server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_refresh_object(self): - self.server.version = '2.8' + def test_refresh_object(self) -> None: + self.server.version = "2.8" self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem('') - workbook._id = '3cc6cd06-89ce-4fdc-b935-5294135d6d42' - with open(REFRESH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + workbook = TSC.WorkbookItem("") + workbook._id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" + with open(REFRESH_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh', - status_code=202, text=response_xml) + m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) self.server.workbooks.refresh(workbook) - def test_delete(self): + def test_delete(self) -> None: with requests_mock.mock() as m: - m.delete(self.baseurl + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42', status_code=204) - self.server.workbooks.delete('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + self.server.workbooks.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.delete, '') + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.delete, "") - def test_update(self): - with open(UPDATE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_update(self) -> None: + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2', text=response_xml) - single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74', show_tabs=True) - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' - single_workbook.owner_id = 'dd2239f6-ddf1-4107-981a-4cf94e415794' - single_workbook.name = 'renamedWorkbook' - single_workbook.data_acceleration_config = {'acceleration_enabled': True, - 'accelerate_now': False, - 'last_updated_at': None, - 'acceleration_status': None} + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_workbook.name = "renamedWorkbook" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } single_workbook = self.server.workbooks.update(single_workbook) - self.assertEqual('1f951daf-4061-451a-9df1-69a8062664f2', single_workbook.id) + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) self.assertEqual(True, single_workbook.show_tabs) - self.assertEqual('1d0304cd-3796-429f-b815-7258370b9b74', single_workbook.project_id) - self.assertEqual('dd2239f6-ddf1-4107-981a-4cf94e415794', single_workbook.owner_id) - self.assertEqual('renamedWorkbook', single_workbook.name) - self.assertEqual(True, single_workbook.data_acceleration_config['acceleration_enabled']) - self.assertEqual(False, single_workbook.data_acceleration_config['accelerate_now']) - - def test_update_missing_id(self): - single_workbook = TSC.WorkbookItem('test') + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_workbook.owner_id) + self.assertEqual("renamedWorkbook", single_workbook.name) + self.assertEqual(True, single_workbook.data_acceleration_config["acceleration_enabled"]) + self.assertEqual(False, single_workbook.data_acceleration_config["accelerate_now"]) + + def test_update_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.update, single_workbook) - def test_update_copy_fields(self): - with open(POPULATE_CONNECTIONS_XML, 'rb') as f: - connection_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') + def test_update_copy_fields(self) -> None: + with open(POPULATE_CONNECTIONS_XML, "rb") as f: + connection_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/connections', text=connection_xml) - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2', text=update_xml) - single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=connection_xml) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_connections(single_workbook) updated_workbook = self.server.workbooks.update(single_workbook) @@ -233,195 +238,202 @@ def test_update_copy_fields(self): self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) self.assertEqual(single_workbook._preview_image, updated_workbook._preview_image) - def test_update_tags(self): - with open(ADD_TAGS_XML, 'rb') as f: - add_tags_xml = f.read().decode('utf-8') - with open(UPDATE_XML, 'rb') as f: - update_xml = f.read().decode('utf-8') - with requests_mock.mock() as m: - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags', text=add_tags_xml) - m.delete(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags/b', status_code=204) - m.delete(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/tags/d', status_code=204) - m.put(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2', text=update_xml) - single_workbook = TSC.WorkbookItem('1d0304cd-3796-429f-b815-7258370b9b74') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' - single_workbook._initial_tags.update(['a', 'b', 'c', 'd']) - single_workbook.tags.update(['a', 'c', 'e']) + def test_update_tags(self) -> None: + with open(ADD_TAGS_XML, "rb") as f: + add_tags_xml = f.read().decode("utf-8") + with open(UPDATE_XML, "rb") as f: + update_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags", text=add_tags_xml) + m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/b", status_code=204) + m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/d", status_code=204) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook._initial_tags.update(["a", "b", "c", "d"]) + single_workbook.tags.update(["a", "c", "e"]) updated_workbook = self.server.workbooks.update(single_workbook) self.assertEqual(single_workbook.tags, updated_workbook.tags) self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) - def test_download(self): + def test_download(self) -> None: with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}) - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_sanitizes_name(self): + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content', - headers={'Content-Disposition': disposition}) - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2') + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") self.assertEqual(os.path.basename(file_path), "NameWithCommas.twbx") self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_extract_only(self): + def test_download_extract_only(self) -> None: # Pretend we're 2.5 for 'extract_only' self.server.version = "2.5" self.baseurl = self.server.workbooks.baseurl with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False', - headers={'Content-Disposition': 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, - complete_qs=True) + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + complete_qs=True, + ) # Technically this shouldn't download a twbx, but we are interested in the qs, not the file - file_path = self.server.workbooks.download('1f951daf-4061-451a-9df1-69a8062664f2', include_extract=False) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", include_extract=False) self.assertTrue(os.path.exists(file_path)) os.remove(file_path) - def test_download_missing_id(self): - self.assertRaises(ValueError, self.server.workbooks.download, '') + def test_download_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.workbooks.download, "") - def test_populate_views(self): - with open(POPULATE_VIEWS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_views(self) -> None: + with open(POPULATE_VIEWS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/views', text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_views(single_workbook) views_list = single_workbook.views - self.assertEqual('097dbe13-de89-445f-b2c3-02f28bd010c1', views_list[0].id) - self.assertEqual('GDP per capita', views_list[0].name) - self.assertEqual('RESTAPISample/sheets/GDPpercapita', views_list[0].content_url) - - self.assertEqual('2c1ab9d7-8d64-4cc6-b495-52e40c60c330', views_list[1].id) - self.assertEqual('Country ranks', views_list[1].name) - self.assertEqual('RESTAPISample/sheets/Countryranks', views_list[1].content_url) - - self.assertEqual('0599c28c-6d82-457e-a453-e52c1bdb00f5', views_list[2].id) - self.assertEqual('Interest rates', views_list[2].name) - self.assertEqual('RESTAPISample/sheets/Interestrates', views_list[2].content_url) - - def test_populate_views_with_usage(self): - with open(POPULATE_VIEWS_USAGE_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') - with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/views?includeUsageStatistics=true', - text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) + self.assertEqual("GDP per capita", views_list[0].name) + self.assertEqual("RESTAPISample/sheets/GDPpercapita", views_list[0].content_url) + + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) + self.assertEqual("Country ranks", views_list[1].name) + self.assertEqual("RESTAPISample/sheets/Countryranks", views_list[1].content_url) + + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) + self.assertEqual("Interest rates", views_list[2].name) + self.assertEqual("RESTAPISample/sheets/Interestrates", views_list[2].content_url) + + def test_populate_views_with_usage(self) -> None: + with open(POPULATE_VIEWS_USAGE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views?includeUsageStatistics=true", + text=response_xml, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_views(single_workbook, usage=True) views_list = single_workbook.views - self.assertEqual('097dbe13-de89-445f-b2c3-02f28bd010c1', views_list[0].id) + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) self.assertEqual(2, views_list[0].total_views) - self.assertEqual('2c1ab9d7-8d64-4cc6-b495-52e40c60c330', views_list[1].id) + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) self.assertEqual(37, views_list[1].total_views) - self.assertEqual('0599c28c-6d82-457e-a453-e52c1bdb00f5', views_list[2].id) + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) self.assertEqual(0, views_list[2].total_views) - def test_populate_views_missing_id(self): - single_workbook = TSC.WorkbookItem('test') + def test_populate_views_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_views, single_workbook) - def test_populate_connections(self): - with open(POPULATE_CONNECTIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_connections(self) -> None: + with open(POPULATE_CONNECTIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/connections', text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_connections(single_workbook) - self.assertEqual('37ca6ced-58d7-4dcf-99dc-f0a85223cbef', single_workbook.connections[0].id) - self.assertEqual('dataengine', single_workbook.connections[0].connection_type) - self.assertEqual('4506225a-0d32-4ab1-82d3-c24e85f7afba', single_workbook.connections[0].datasource_id) - self.assertEqual('World Indicators', single_workbook.connections[0].datasource_name) + self.assertEqual("37ca6ced-58d7-4dcf-99dc-f0a85223cbef", single_workbook.connections[0].id) + self.assertEqual("dataengine", single_workbook.connections[0].connection_type) + self.assertEqual("4506225a-0d32-4ab1-82d3-c24e85f7afba", single_workbook.connections[0].datasource_id) + self.assertEqual("World Indicators", single_workbook.connections[0].datasource_name) - def test_populate_permissions(self): - with open(POPULATE_PERMISSIONS_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_populate_permissions(self) -> None: + with open(POPULATE_PERMISSIONS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + '/21778de4-b7b9-44bc-a599-1506a2639ace/permissions', text=response_xml) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + m.get(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" self.server.workbooks.populate_permissions(single_workbook) permissions = single_workbook.permissions - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny - }) - - def test_add_permissions(self): - with open(UPDATE_PERMISSIONS, 'rb') as f: - response_xml = f.read().decode('utf-8') - - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '21778de4-b7b9-44bc-a599-1506a2639ace' + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual( + permissions[0].capabilities, + { + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + }, + ) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny, + }, + ) + + def test_add_permissions(self) -> None: + with open(UPDATE_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") + + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") - new_permissions = [ - PermissionsRule(bob, {'Write': 'Allow'}), - PermissionsRule(group_of_people, {'Read': 'Deny'}) - ] + new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] with requests_mock.mock() as m: m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) permissions = self.server.workbooks.update_permissions(single_workbook, new_permissions) - self.assertEqual(permissions[0].grantee.tag_name, 'group') - self.assertEqual(permissions[0].grantee.id, '5e5e1978-71fa-11e4-87dd-7382f5c437af') - self.assertDictEqual(permissions[0].capabilities, { - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny - }) - - self.assertEqual(permissions[1].grantee.tag_name, 'user') - self.assertEqual(permissions[1].grantee.id, '7c37ee24-c4b1-42b6-a154-eaeab7ee330a') - self.assertDictEqual(permissions[1].capabilities, { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow - }) - - def test_populate_connections_missing_id(self): - single_workbook = TSC.WorkbookItem('test') - self.assertRaises(TSC.MissingRequiredFieldError, - self.server.workbooks.populate_connections, - single_workbook) - - def test_populate_pdf(self): + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) + + def test_populate_connections_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_connections, single_workbook) + + def test_populate_pdf(self) -> None: self.server.version = "3.4" self.baseurl = self.server.workbooks.baseurl with open(POPULATE_PDF, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", - content=response) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" type = TSC.PDFRequestOptions.PageType.A5 orientation = TSC.PDFRequestOptions.Orientation.Landscape @@ -430,307 +442,442 @@ def test_populate_pdf(self): self.server.workbooks.populate_pdf(single_workbook, req_option) self.assertEqual(response, single_workbook.pdf) - def test_populate_preview_image(self): - with open(POPULATE_PREVIEW_IMAGE, 'rb') as f: + def test_populate_powerpoint(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_POWERPOINT, "rb") as f: response = f.read() with requests_mock.mock() as m: - m.get(self.baseurl + '/1f951daf-4061-451a-9df1-69a8062664f2/previewImage', content=response) - single_workbook = TSC.WorkbookItem('test') - single_workbook._id = '1f951daf-4061-451a-9df1-69a8062664f2' + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + self.server.workbooks.populate_powerpoint(single_workbook) + self.assertEqual(response, single_workbook.powerpoint) + + def test_populate_preview_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/previewImage", content=response) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" self.server.workbooks.populate_preview_image(single_workbook) self.assertEqual(response, single_workbook.preview_image) - def test_populate_preview_image_missing_id(self): - single_workbook = TSC.WorkbookItem('test') - self.assertRaises(TSC.MissingRequiredFieldError, - self.server.workbooks.populate_preview_image, - single_workbook) + def test_populate_preview_image_missing_id(self) -> None: + single_workbook = TSC.WorkbookItem("test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_preview_image, single_workbook) - def test_publish(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_publish(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - sample_workbook, - publish_mode) + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - self.assertEqual('fe0b4e89-73f4-435e-952d-3a263fbfa56c', new_workbook.views[0].id) - self.assertEqual('GDP per capita', new_workbook.views[0].name) - self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) - - def test_publish_a_packaged_file_object(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) + self.assertEqual("GDP per capita", new_workbook.views[0].name) + self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + + def test_publish_a_packaged_file_object(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - with open(sample_workbook, 'rb') as fp: + with open(sample_workbook, "rb") as fp: publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - fp, - publish_mode) + new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - self.assertEqual('fe0b4e89-73f4-435e-952d-3a263fbfa56c', new_workbook.views[0].id) - self.assertEqual('GDP per capita', new_workbook.views[0].name) - self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) - - def test_publish_non_packeged_file_object(self): - - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) + self.assertEqual("GDP per capita", new_workbook.views[0].name) + self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + + def test_publish_non_packeged_file_object(self) -> None: + + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'RESTAPISample.twb') + sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") - with open(sample_workbook, 'rb') as fp: + with open(sample_workbook, "rb") as fp: publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - fp, - publish_mode) + new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - self.assertEqual('a8076ca1-e9d8-495e-bae6-c684dbb55836', new_workbook.id) - self.assertEqual('RESTAPISample', new_workbook.name) - self.assertEqual('RESTAPISample_0', new_workbook.content_url) + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) self.assertEqual(False, new_workbook.show_tabs) self.assertEqual(1, new_workbook.size) - self.assertEqual('2016-08-18T18:33:24Z', format_datetime(new_workbook.created_at)) - self.assertEqual('2016-08-18T20:31:34Z', format_datetime(new_workbook.updated_at)) - self.assertEqual('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', new_workbook.project_id) - self.assertEqual('default', new_workbook.project_name) - self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_workbook.owner_id) - self.assertEqual('fe0b4e89-73f4-435e-952d-3a263fbfa56c', new_workbook.views[0].id) - self.assertEqual('GDP per capita', new_workbook.views[0].name) - self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) - - def test_publish_with_hidden_view(self): - with open(PUBLISH_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) + self.assertEqual("GDP per capita", new_workbook.views[0].name) + self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + + def test_publish_path_object(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = Path(TEST_ASSET_DIR) / "SampleWB.twbx" publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, - sample_workbook, - publish_mode, - hidden_views=['GDP per capita']) + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + + self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) + self.assertEqual("RESTAPISample", new_workbook.name) + self.assertEqual("RESTAPISample_0", new_workbook.content_url) + self.assertEqual(False, new_workbook.show_tabs) + self.assertEqual(1, new_workbook.size) + self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) + self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) + self.assertEqual("default", new_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) + self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) + self.assertEqual("GDP per capita", new_workbook.views[0].name) + self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + + def test_publish_with_hidden_views_on_workbook(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + + new_workbook.hidden_views = ["GDP per capita"] + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + self.assertTrue(re.search(rb"<\/views>", request_body)) + self.assertTrue(re.search(rb"<\/views>", request_body)) + + # this tests the old method of including workbook views as a parameter for publishing + # should be removed when that functionality is removed + # see https://github.com/tableau/server-client-python/pull/617 + def test_publish_with_hidden_view(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + + new_workbook = self.server.workbooks.publish( + new_workbook, sample_workbook, publish_mode, hidden_views=["GDP per capita"] + ) request_body = m._adapter.request_history[0]._request.body # order of attributes in xml is unspecified - self.assertTrue(re.search(rb'<\/views>', request_body)) - self.assertTrue(re.search(rb'<\/views>', request_body)) + self.assertTrue(re.search(rb"<\/views>", request_body)) + self.assertTrue(re.search(rb"<\/views>", request_body)) - def test_publish_with_query_params(self): - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + def test_publish_with_query_params(self) -> None: + 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) - new_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew - self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, - as_job=True, skip_connection_check=True) + self.server.workbooks.publish( + new_workbook, sample_workbook, publish_mode, as_job=True, skip_connection_check=True + ) request_query_params = m._adapter.request_history[0].qs - self.assertTrue('asjob' in request_query_params) - self.assertTrue(request_query_params['asjob']) - self.assertTrue('skipconnectioncheck' in request_query_params) - self.assertTrue(request_query_params['skipconnectioncheck']) + self.assertTrue("asjob" in request_query_params) + self.assertTrue(request_query_params["asjob"]) + self.assertTrue("skipconnectioncheck" in request_query_params) + self.assertTrue(request_query_params["skipconnectioncheck"]) - def test_publish_async(self): - self.server.version = '3.0' + def test_publish_async(self) -> None: + 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 open(PUBLISH_ASYNC_XML, "rb") as f: + response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem(name='Sample', - show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx') + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew - new_job = self.server.workbooks.publish(new_workbook, - sample_workbook, - publish_mode, - as_job=True) + new_job = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True) - self.assertEqual('7c3d599e-949f-44c3-94a1-f30ba85757e4', new_job.id) - self.assertEqual('PublishWorkbook', new_job.type) - self.assertEqual('0', new_job.progress) - self.assertEqual('2018-06-29T23:22:32Z', format_datetime(new_job.created_at)) + self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + self.assertEqual("PublishWorkbook", new_job.type) + self.assertEqual("0", new_job.progress) + self.assertEqual("2018-06-29T23:22:32Z", format_datetime(new_job.created_at)) self.assertEqual(1, new_job.finish_code) - def test_publish_invalid_file(self): - new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, '.', - self.server.PublishMode.CreateNew) + def test_publish_invalid_file(self) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, ".", self.server.PublishMode.CreateNew) - def test_publish_invalid_file_type(self): - new_workbook = TSC.WorkbookItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - self.assertRaises(ValueError, self.server.workbooks.publish, - new_workbook, os.path.join(TEST_ASSET_DIR, 'SampleDS.tds'), - self.server.PublishMode.CreateNew) + def test_publish_invalid_file_type(self) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + self.assertRaises( + ValueError, + self.server.workbooks.publish, + new_workbook, + os.path.join(TEST_ASSET_DIR, "SampleDS.tds"), + self.server.PublishMode.CreateNew, + ) - def test_publish_unnamed_file_object(self): - new_workbook = TSC.WorkbookItem('test') + def test_publish_unnamed_file_object(self) -> None: + new_workbook = TSC.WorkbookItem("test") - with open(os.path.join(TEST_ASSET_DIR, 'SampleWB.twbx')) as f: + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: - self.assertRaises(ValueError, self.server.workbooks.publish, - new_workbook, f, self.server.PublishMode.CreateNew - ) + self.assertRaises( + ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew + ) - def test_publish_file_object_of_unknown_type_raises_exception(self): - new_workbook = TSC.WorkbookItem('test') + def test_publish_non_bytes_file_object(self) -> None: + new_workbook = TSC.WorkbookItem("test") + + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: + + self.assertRaises( + TypeError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew + ) + + def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: + new_workbook = TSC.WorkbookItem("test") with BytesIO() as file_object: - file_object.write(bytes.fromhex('89504E470D0A1A0A')) + file_object.write(bytes.fromhex("89504E470D0A1A0A")) file_object.seek(0) - self.assertRaises(ValueError, self.server.workbooks.publish, new_workbook, - file_object, self.server.PublishMode.CreateNew) - - def test_publish_multi_connection(self): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + self.assertRaises( + ValueError, self.server.workbooks.publish, new_workbook, file_object, self.server.PublishMode.CreateNew + ) + + def test_publish_multi_connection(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) connection2 = TSC.ConnectionItem() - connection2.server_address = 'pgsql.test.com' - connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) # Can't use ConnectionItem parser due to xml namespace problems - connection_results = ET.fromstring(response).findall('.//connection') + connection_results = fromstring(response).findall(".//connection") - self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com') - self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test') - self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com') - self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret') + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") + self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - def test_publish_single_connection(self): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + def test_publish_single_connection(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + connection_creds = TSC.ConnectionCredentials("test", "secret", True) response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) # Can't use ConnectionItem parser due to xml namespace problems - credentials = ET.fromstring(response).findall('.//connectionCredentials') + credentials = fromstring(response).findall(".//connectionCredentials") self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get('name', None), 'test') - self.assertEqual(credentials[0].get('password', None), 'secret') - self.assertEqual(credentials[0].get('embed', None), 'true') + self.assertEqual(credentials[0].get("name", None), "test") + self.assertEqual(credentials[0].get("password", None), "secret") + self.assertEqual(credentials[0].get("embed", None), "true") - def test_credentials_and_multi_connect_raises_exception(self): - new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, - project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + def test_credentials_and_multi_connect_raises_exception(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - connection_creds = TSC.ConnectionCredentials('test', 'secret', True) + connection_creds = TSC.ConnectionCredentials("test", "secret", True) connection1 = TSC.ConnectionItem() - connection1.server_address = 'mysql.test.com' - connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True) + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) with self.assertRaises(RuntimeError): - response = RequestFactory.Workbook._generate_xml(new_workbook, - connection_credentials=connection_creds, - connections=[connection1]) + response = RequestFactory.Workbook._generate_xml( + new_workbook, connection_credentials=connection_creds, connections=[connection1] + ) - def test_synchronous_publish_timeout_error(self): + def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: - m.register_uri('POST', self.baseurl, status_code=504) + m.register_uri("POST", self.baseurl, status_code=504) - new_workbook = TSC.WorkbookItem(project_id='') + new_workbook = TSC.WorkbookItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegex(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, + ) - def test_delete_extracts_all(self): + def test_delete_extracts_all(self) -> None: self.server.version = "3.10" self.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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract', status_code=200) - self.server.workbooks.delete_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200, text=response_xml + ) + self.server.workbooks.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_all(self): + def test_create_extracts_all(self) -> None: self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + 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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42') + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_one(self): + def test_create_extracts_one(self) -> None: self.server.version = "3.10" self.baseurl = self.server.workbooks.baseurl - datasource = TSC.DatasourceItem('test') - datasource._id = '1f951daf-4061-451a-9df1-69a8062664f2' + datasource = TSC.DatasourceItem("test") + datasource._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with open(PUBLISH_ASYNC_XML, 'rb') as f: - response_xml = f.read().decode('utf-8') + 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 + '/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract', - status_code=200, text=response_xml) - self.server.workbooks.create_extract('3cc6cd06-89ce-4fdc-b935-5294135d6d42', False, datasource) + m.post( + self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml + ) + self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", False, datasource) + + def test_revisions(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with open(REVISION_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + self.server.workbooks.populate_revisions(workbook) + revisions = workbook.revisions + + self.assertEqual(len(revisions), 3) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) + self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) + self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) + + self.assertEqual(False, revisions[0].deleted) + self.assertEqual(False, revisions[0].current) + self.assertEqual(False, revisions[1].deleted) + self.assertEqual(False, revisions[1].current) + self.assertEqual(False, revisions[2].deleted) + self.assertEqual(True, revisions[2].current) + + self.assertEqual("Cassie", revisions[0].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) + self.assertIsNone(revisions[1].user_name) + self.assertIsNone(revisions[1].user_id) + self.assertEqual("Cassie", revisions[2].user_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) + + def test_delete_revision(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + self.server.workbooks.delete_revision(workbook.id, "3") + + def test_download_revision(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index 69188fa4a..d45899e2d 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -1,4 +1,5 @@ import unittest + import tableauserverclient as TSC From 9fb183d122adb5b7111dbd4ea5dd9573ee4103d0 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 2 Jun 2022 21:15:28 -0700 Subject: [PATCH 278/567] Jac/amar kumar yadav 1044 (#1053) * added new permission populate methods Authored-by: Amar Yadav --- tableauserverclient/models/permissions_item.py | 4 ++++ tableauserverclient/models/project_item.py | 8 ++++++++ .../server/endpoint/projects_endpoint.py | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ca56248..fcb758279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,6 +37,9 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" + RunExplainData = "RunExplainData" + CreateRefreshMetrics = "CreateRefreshMetrics" + SaveAs = "SaveAs" class Resource: Workbook = "workbook" @@ -45,6 +48,7 @@ class Resource: Table = "table" Database = "database" View = "view" + Lens = "lens" class PermissionsRule(object): diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 177b3e016..a94d135c2 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -31,6 +31,7 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None + self._default_lens_permissions = None @property def content_permissions(self): @@ -69,6 +70,13 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() + @property + def default_lens_permissions(self): + if self._default_lens_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_lens_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index b21ba3682..8edf66f39 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -103,6 +103,10 @@ def populate_datasource_default_permissions(self, item): def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) + @api(version="3.4") + def populate_lens_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Permission.Resource.Lens) + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @@ -115,6 +119,10 @@ def update_datasource_default_permissions(self, item, rules): def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) + @api(version="3.4") + def update_lens_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Lens) + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @@ -126,3 +134,7 @@ def delete_datasource_default_permissions(self, item, rule): @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) + + @api(version="3.4") + def delete_lens_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Lens) From b9a10fd26b75b584e2fa19a7029784005fdd09cf Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 3 Jun 2022 20:46:42 -0700 Subject: [PATCH 279/567] Revert "Jac/amar kumar yadav 1044 (#1053)" (#1055) This reverts commit 9fb183d122adb5b7111dbd4ea5dd9573ee4103d0. --- tableauserverclient/models/permissions_item.py | 4 ---- tableauserverclient/models/project_item.py | 8 -------- .../server/endpoint/projects_endpoint.py | 12 ------------ 3 files changed, 24 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index fcb758279..71ca56248 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,9 +37,6 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" - RunExplainData = "RunExplainData" - CreateRefreshMetrics = "CreateRefreshMetrics" - SaveAs = "SaveAs" class Resource: Workbook = "workbook" @@ -48,7 +45,6 @@ class Resource: Table = "table" Database = "database" View = "view" - Lens = "lens" class PermissionsRule(object): diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index a94d135c2..177b3e016 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -31,7 +31,6 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None - self._default_lens_permissions = None @property def content_permissions(self): @@ -70,13 +69,6 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() - @property - def default_lens_permissions(self): - if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) - return self._default_lens_permissions() - @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 8edf66f39..b21ba3682 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -103,10 +103,6 @@ def populate_datasource_default_permissions(self, item): def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) - @api(version="3.4") - def populate_lens_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Lens) - @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) @@ -119,10 +115,6 @@ def update_datasource_default_permissions(self, item, rules): def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) - @api(version="3.4") - def update_lens_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Lens) - @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) @@ -134,7 +126,3 @@ def delete_datasource_default_permissions(self, item, rule): @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) - - @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Lens) From 1eeaca8709f548b73d7306a1251322c784e656c8 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 6 Jun 2022 14:53:28 -0700 Subject: [PATCH 280/567] Prepare release 0.19 (#1049) * Add new samples and delete redundant ones * Clean up hidden_views by making it an attribute of WorkbookItem * Add type hints for workbook and data source revisions, data alerts, Favorites, Flows, groups, permissions, projects, flow runs, site, subscriptions, Users, webhooks * add get_by_id method and test for schedules * Allow null value for user quota tiers * fix workbook.delete_extract * add publish to pypi action fix xml generation for items * Add Status, ParentProjectId and StartedAt filters for jobs endpoint * make project_id nullable to support "Personal Space" for workbooks * create single Credentials class * Reassign content on user removal * add redaction method to remove passwords when logging requests and responses, which can contain embedded credentials. * remove support for python 3.6 (add python version enforcement in setup.py) * Extract refreshable item IDs from job XML response * Do not eagerly fetch content when a stream was requested * Fix QuerySet slicing logic * add CRUD methods for default permissions * refactor Resource Types and add sample code --- .github/workflows/run-tests.yml | 2 +- .gitignore | 1 - README.md | 2 +- publish.sh | 7 +- pyproject.toml | 18 ++++ samples/add_default_permission.py | 2 +- samples/create_group.py | 2 +- samples/create_project.py | 23 +++- samples/create_schedules.py | 2 +- samples/download_view_image.py | 77 ------------- samples/export.py | 31 ++++-- samples/export_wb.py | 101 ------------------ samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/kill_all_jobs.py | 2 +- samples/list.py | 2 +- samples/login.py | 2 +- samples/metadata_query.py | 2 +- samples/move_workbook_projects.py | 2 +- samples/move_workbook_sites.py | 2 +- samples/publish_datasource.py | 2 +- samples/publish_workbook.py | 2 +- samples/query_permissions.py | 2 +- samples/refresh.py | 2 +- samples/refresh_tasks.py | 2 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- setup.py | 8 +- tableauserverclient/__init__.py | 4 + tableauserverclient/helpers/__init__.py | 1 + tableauserverclient/helpers/strings.py | 45 ++++++++ tableauserverclient/models/__init__.py | 5 +- tableauserverclient/models/data_alert_item.py | 6 ++ tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 1 + tableauserverclient/models/flow_item.py | 8 ++ tableauserverclient/models/flow_run_item.py | 5 + tableauserverclient/models/job_item.py | 26 +++++ .../models/permissions_item.py | 16 +-- .../models/personal_access_token_auth.py | 17 --- tableauserverclient/models/project_item.py | 21 +++- tableauserverclient/models/reference_item.py | 5 + tableauserverclient/models/tableau_auth.py | 75 +++++++++---- tableauserverclient/models/tableau_types.py | 31 ++++++ tableauserverclient/models/user_item.py | 6 ++ tableauserverclient/models/view_item.py | 10 +- tableauserverclient/models/workbook_item.py | 2 +- tableauserverclient/server/__init__.py | 4 + .../server/endpoint/databases_endpoint.py | 10 +- .../server/endpoint/datasources_endpoint.py | 2 +- .../endpoint/default_permissions_endpoint.py | 59 +++++----- .../server/endpoint/endpoint.py | 75 +++++++------ .../server/endpoint/permissions_endpoint.py | 12 ++- .../server/endpoint/projects_endpoint.py | 36 ++++--- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 6 +- .../server/endpoint/views_endpoint.py | 12 +-- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/query.py | 3 +- tableauserverclient/server/request_factory.py | 21 +++- tableauserverclient/server/request_options.py | 3 + test/assets/job_get_by_id_failed_workbook.xml | 9 ++ test/assets/queryset_slicing_page_1.xml | 46 ++++++++ test/assets/queryset_slicing_page_2.xml | 46 ++++++++ test/request_factory/__init__.py | 0 .../test_datasource_requests.py | 15 +++ .../request_factory/test_workbook_requests.py | 55 ++++++++++ test/test_dqw.py | 11 ++ test/test_endpoint.py | 40 +++++++ test/test_job.py | 19 +++- test/test_regression_tests.py | 53 ++++++--- test/test_request_option.py | 51 +++++---- test/test_user.py | 10 ++ test/test_workbook_model.py | 6 -- 74 files changed, 789 insertions(+), 418 deletions(-) create mode 100644 pyproject.toml delete mode 100644 samples/download_view_image.py delete mode 100644 samples/export_wb.py create mode 100644 tableauserverclient/helpers/__init__.py create mode 100644 tableauserverclient/helpers/strings.py delete mode 100644 tableauserverclient/models/personal_access_token_auth.py create mode 100644 tableauserverclient/models/tableau_types.py create mode 100644 test/assets/job_get_by_id_failed_workbook.xml create mode 100644 test/assets/queryset_slicing_page_1.xml create mode 100644 test/assets/queryset_slicing_page_2.xml create mode 100644 test/request_factory/__init__.py create mode 100644 test/request_factory/test_datasource_requests.py create mode 100644 test/request_factory/test_workbook_requests.py create mode 100644 test/test_dqw.py create mode 100644 test/test_endpoint.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9fe99f953..60a209b61 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10'] runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 5efc6b31d..d8caf99a9 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,6 @@ target/ # poetry poetry.lock -pyproject.toml # celery beat schedule file celerybeat-schedule diff --git a/README.md b/README.md index f14c23230..b21c2665d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code for the library and sample files showing how to use it. Python versions 3.6 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. As of May 2022, Python versions 3.7 and up are supported. To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. diff --git a/publish.sh b/publish.sh index 02812c1c3..46d54a1ee 100755 --- a/publish.sh +++ b/publish.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash +# tag the release version and confirm a clean version number +git tag vxxxx +git describe --tag --dirty --always + set -e rm -rf dist -python3 setup.py sdist -python3 setup.py bdist_wheel +python setup.py sdist bdist_wheel twine upload dist/* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..1884a6b37 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=45.0", "versioneer-518", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 120 +target-version = ['py37', 'py38', 'py39', 'py310'] + +[tool.mypy] +disable_error_code = [ + 'misc', + 'import' +] +files = [ + "tableauserverclient", + "test" +] +show_error_codes = true diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 56d3afdf1..829190359 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to add default permissions using TSC -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. # # In order to demonstrate adding a new default permission, this sample will create # a new project and add a new capability to the new project, for the default "All users" group. diff --git a/samples/create_group.py b/samples/create_group.py index 16016398d..3875ffea5 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -2,7 +2,7 @@ # This script demonstrates how to create a group using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/create_project.py b/samples/create_project.py index 6271f3d93..8b2ec3354 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -4,7 +4,7 @@ # parent_id. # # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse @@ -21,7 +21,8 @@ def create_project(server, project_item, samples=False): return project_item except TSC.ServerResponseError: print("We have already created this project: %s" % project_item.name) - sys.exit(1) + project_items = server.projects.filter(name=project_item.name) + return project_items[0] def main(): @@ -52,7 +53,8 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server) + server = TSC.Server(args.server, http_options={"verify": False}) + server.use_server_version() with server.auth.sign_in(tableau_auth): # Use highest Server REST API version available @@ -73,6 +75,21 @@ def main(): # Projects can be updated changed_project = server.projects.update(grand_child_project, samples=True) + server.projects.populate_workbook_default_permissions(changed_project), + server.projects.populate_flow_default_permissions(changed_project), + server.projects.populate_lens_default_permissions(changed_project), # uses same as workbook + server.projects.populate_datasource_default_permissions(changed_project), + server.projects.populate_permissions(changed_project) + # Projects have default permissions set for the object types they contain + print("Permissions from project {}:".format(changed_project.id)) + print(changed_project.permissions) + print( + changed_project.default_workbook_permissions, + changed_project.default_datasource_permissions, + changed_project.default_lens_permissions, + changed_project.default_flow_permissions, + ) + if __name__ == "__main__": main() diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 4fe6db5a4..87b43dbca 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -2,7 +2,7 @@ # This script demonstrates how to create schedules using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/download_view_image.py b/samples/download_view_image.py deleted file mode 100644 index 3b2fbac1c..000000000 --- a/samples/download_view_image.py +++ /dev/null @@ -1,77 +0,0 @@ -#### -# This script demonstrates how to use the Tableau Server Client -# to download a high resolution image of a view from Tableau Server. -# -# For more information, refer to the documentations on 'Query View Image' -# (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) -# -# To run the script, you must have installed Python 3.6 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - - parser = argparse.ArgumentParser(description="Download image of a specified view.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample - parser.add_argument("--view-name", "-vn", required=True, help="name of view to download an image of") - parser.add_argument("--filepath", "-f", required=True, help="filepath to save the image returned") - parser.add_argument("--maxage", "-m", required=False, help="max age of the image in the cache in minutes.") - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - # Step 1: Sign in to server. - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - # Step 2: Query for the view that we want an image of - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.view_name) - ) - all_views, pagination_item = server.views.get(req_option) - if not all_views: - raise LookupError("View with the specified name was not found.") - view_item = all_views[0] - - max_age = args.maxage - if not max_age: - max_age = 1 - - image_req_option = TSC.ImageRequestOptions( - imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=max_age - ) - server.views.populate_image(view_item, image_req_option) - - with open(args.filepath, "wb") as image_file: - image_file.write(view_item.image) - - print("View image saved to {0}".format(args.filepath)) - - -if __name__ == "__main__": - main() diff --git a/samples/export.py b/samples/export.py index 701f93fee..4c26770b9 100644 --- a/samples/export.py +++ b/samples/export.py @@ -2,7 +2,7 @@ # This script demonstrates how to export a view using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse @@ -40,10 +40,13 @@ def main(): group.add_argument( "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + + parser.add_argument("--workbook", 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") - parser.add_argument("resource_id", help="LUID for the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") args = parser.parse_args() @@ -52,34 +55,46 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): - views = filter(lambda x: x.id == args.resource_id or x.name == args.resource_id, TSC.Pager(server.views.get)) - view = list(views).pop() # in python 3 filter() returns a filter object + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print("No item found for id {}".format(args.resource_id)) + exit(1) + print("Item found: {}".format(item.name)) # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. # We unroll that information into methods we can call, or objects we can create by using getattr() (populate_func_name, option_factory_name, member_name, extension) = args.type populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + option_factory = getattr(TSC, option_factory_name) if args.filter: options = option_factory().vf(*args.filter.split(":")) else: options = None + if args.file: filename = args.file else: filename = "out.{}".format(extension) - populate(view, options) + populate(item, options) with open(filename, "wb") as f: if member_name == "csv": - f.writelines(getattr(view, member_name)) + f.writelines(getattr(item, member_name)) else: - f.write(getattr(view, member_name)) + f.write(getattr(item, member_name)) print("saved to " + filename) diff --git a/samples/export_wb.py b/samples/export_wb.py deleted file mode 100644 index 2376ee62b..000000000 --- a/samples/export_wb.py +++ /dev/null @@ -1,101 +0,0 @@ -#### -# This sample uses the PyPDF2 library for combining pdfs together to get the full pdf for all the views in a -# workbook. -# -# You will need to do `pip install PyPDF2` to use this sample. -# -# To run the script, you must have installed Python 3.6 or later. -#### - - -import argparse -import logging -import tempfile -import shutil -import functools -import os.path - -import tableauserverclient as TSC - -try: - import PyPDF2 -except ImportError: - print("Please `pip install PyPDF2` to use this sample") - import sys - - sys.exit(1) - - -def get_views_for_workbook(server, workbook_id): # -> Iterable of views - workbook = server.workbooks.get_by_id(workbook_id) - server.workbooks.populate_views(workbook) - return workbook.views - - -def download_pdf(server, tempdir, view): # -> Filename to downloaded pdf - logging.info("Exporting {}".format(view.id)) - destination_filename = os.path.join(tempdir, view.id) - server.views.populate_pdf(view) - with file(destination_filename, "wb") as f: - f.write(view.pdf) - - return destination_filename - - -def combine_into(dest_pdf, filename): # -> None - dest_pdf.append(filename) - return dest_pdf - - -def cleanup(tempdir): - shutil.rmtree(tempdir) - - -def main(): - parser = argparse.ArgumentParser(description="Export to PDF all of the views in a workbook.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample - parser.add_argument("--file", "-f", default="out.pdf", help="filename to store the exported data") - parser.add_argument("resource_id", help="LUID for the workbook") - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - tempdir = tempfile.mkdtemp("tsc") - logging.debug("Saving to tempdir: %s", tempdir) - - try: - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) - with server.auth.sign_in(tableau_auth): - get_list = functools.partial(get_views_for_workbook, server) - download = functools.partial(download_pdf, server, tempdir) - - downloaded = (download(x) for x in get_list(args.resource_id)) - output = reduce(combine_into, downloaded, PyPDF2.PdfFileMerger()) - with file(args.file, "wb") as f: - output.write(f) - finally: - cleanup(tempdir) - - -if __name__ == "__main__": - main() diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index e4f2c2bee..c63764134 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -2,7 +2,7 @@ # This script demonstrates how to filter and sort groups using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 628b1c972..bd43cd209 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to filter and sort on the name of the projects present on site. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 02f19d976..1a833f938 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to kill all of the running jobs # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/list.py b/samples/list.py index db0b7c790..814c1b9ca 100644 --- a/samples/list.py +++ b/samples/list.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to list all of the workbooks or datasources # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/login.py b/samples/login.py index c459b9370..f0ff9ad49 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 65df9ddb0..26f8f94fa 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use the metadata API to query information on a published data source # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 22465925f..884c7eab1 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -4,7 +4,7 @@ # a workbook that matches a given name and update it to be in # the desired project. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index c473712e4..a2d11bdfe 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index ad929fd99..eecbe7088 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -15,7 +15,7 @@ # more information on personal access tokens, refer to the documentations: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index c553eda0b..3cc27c582 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/query_permissions.py b/samples/query_permissions.py index c0d1c3afa..0c285d4c3 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -1,6 +1,6 @@ #### # This script demonstrates how to query for permissions using TSC -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. # # Example usage: 'python query_permissions.py -s https://10ax.online.tableau.com --site # devSite123 -u tabby@tableau.com workbook b4065286-80f0-11ea-af1b-cb7191f48e45' diff --git a/samples/refresh.py b/samples/refresh.py index 18a7f36e2..f90441224 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to use trigger a refresh on a datasource or workbook # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 6ef781544..2bfc85621 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index decdc223f..9b3dbc236 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -2,7 +2,7 @@ # This script demonstrates how to set the refresh schedule for # a workbook or datasource. # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### diff --git a/samples/update_connection.py b/samples/update_connection.py index 44f8ec6c0..e27b4477f 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -1,7 +1,7 @@ #### # This script demonstrates how to update a connections credentials on a server to embed the credentials # -# To run the script, you must have installed Python 3.6 or later. +# To run the script, you must have installed Python 3.7 or later. #### import argparse diff --git a/setup.py b/setup.py index ae19dcd26..24d35250c 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ # This makes work easier for offline installs or low bandwidth machines needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy==0.910'] +test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy>=0.920'] setup( name='tableauserverclient', @@ -25,7 +25,10 @@ author_email='github@tableau.com', url='https://github.com/tableau/server-client-python', package_data={'tableauserverclient':['py.typed']}, - packages=['tableauserverclient', 'tableauserverclient.models', 'tableauserverclient.server', + packages=['tableauserverclient', + 'tableauserverclient.helpers', + 'tableauserverclient.models', + 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], license='MIT', description='A Python module for working with the Tableau Server REST API.', @@ -37,6 +40,7 @@ 'defusedxml>=0.7.1', 'requests>=2.11,<3.0', ], + python_requires='>3.7.0', tests_require=test_requirements, extras_require={ 'test': test_requirements diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 897c69fb0..592551b4e 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -37,6 +37,9 @@ FlowRunItem, RevisionItem, MetricItem, + TableauItem, + Resource, + plural_type, ) from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( @@ -52,6 +55,7 @@ NotSignedInError, Pager, ) +from .helpers import * __version__ = get_versions()["version"] __VERSION__ = __version__ diff --git a/tableauserverclient/helpers/__init__.py b/tableauserverclient/helpers/__init__.py new file mode 100644 index 000000000..7daf0d490 --- /dev/null +++ b/tableauserverclient/helpers/__init__.py @@ -0,0 +1 @@ +from .strings import * diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py new file mode 100644 index 000000000..e51a6611a --- /dev/null +++ b/tableauserverclient/helpers/strings.py @@ -0,0 +1,45 @@ +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar + + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +# usage: _redact_any_type("") +# -> b" +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed + return tostring(root, encoding=encoding) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index f72878366..58e5ed6d1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -22,8 +22,6 @@ from .metric_item import MetricItem from .pagination_item import PaginationItem from .permissions_item import PermissionsRule, Permission -from .personal_access_token_auth import PersonalAccessTokenAuth -from .personal_access_token_auth import PersonalAccessTokenAuth from .project_item import ProjectItem from .revision_item import RevisionItem from .schedule_item import ScheduleItem @@ -31,7 +29,8 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import TableauAuth +from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +from .tableau_types import Resource, TableauItem, plural_type from .target import Target from .task_item import TaskItem from .user_item import UserItem diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 1455743cd..3882d14eb 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -12,6 +12,12 @@ from datetime import datetime +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + class DataAlertItem(object): class Frequency: Once = "Once" diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 862a51a11..3d5a00a1a 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -1,3 +1,5 @@ +import logging + from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError @@ -242,11 +244,13 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, - "_default_{content}_permissions".format(content=content_type), + attr, permissions, ) + logging.getLogger().debug({"type": attr, "value": getattr(self, attr)}) def _set_data_quality_warnings(self, dqw): self._data_quality_warnings = dqw diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index c7823918f..37ec1449a 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from .permissions_item import PermissionsRule from .connection_item import ConnectionItem + from .revision_item import RevisionItem import datetime diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 96a99c943..d957f5e14 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -12,6 +12,14 @@ if TYPE_CHECKING: import datetime +from typing import List, Optional, TYPE_CHECKING, Set + +if TYPE_CHECKING: + import datetime + from .connection_item import ConnectionItem + from .permissions_item import Permission + from .dqw_item import DQWItem + class FlowItem(object): def __init__(self, project_id: str, name: Optional[str] = None) -> None: diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f6ce3d0d5..ce859a65b 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -5,6 +5,11 @@ from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + from datetime import datetime + +from typing import Dict, List, Optional, Type, TYPE_CHECKING + if TYPE_CHECKING: from datetime import datetime diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index e05c42e22..39562cd45 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -31,6 +31,8 @@ def __init__( finish_code: int = 0, notes: Optional[List[str]] = None, mode: Optional[str] = None, + workbook_id: Optional[str] = None, + datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, ): self._id = id_ @@ -42,6 +44,8 @@ def __init__( self._finish_code = finish_code self._notes: List[str] = notes or [] self._mode = mode + self._workbook_id = workbook_id + self._datasource_id = datasource_id self._flow_run = flow_run @property @@ -85,6 +89,22 @@ def mode(self, value: str) -> None: # check for valid data here self._mode = value + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @workbook_id.setter + def workbook_id(self, value: Optional[str]) -> None: + self._workbook_id = value + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @datasource_id.setter + def datasource_id(self, value: Optional[str]) -> None: + self._datasource_id = value + @property def flow_run(self): return self._flow_run @@ -119,6 +139,10 @@ def _parse_element(cls, element, ns): finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None mode = element.get("mode", None) + workbook = element.find(".//t:workbook[@id]", namespaces=ns) + workbook_id = workbook.get("id") if workbook is not None else None + datasource = element.find(".//t:datasource[@id]", namespaces=ns) + datasource_id = datasource.get("id") if datasource is not None else None flow_run = None for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns): flow_run = FlowRunItem() @@ -136,6 +160,8 @@ def _parse_element(cls, element, ns): finish_code, notes, mode, + workbook_id, + datasource_id, flow_run, ) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ca56248..1c1e9db4d 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -37,14 +37,9 @@ class Capability: ViewUnderlyingData = "ViewUnderlyingData" WebAuthoring = "WebAuthoring" Write = "Write" - - class Resource: - Workbook = "workbook" - Datasource = "datasource" - Flow = "flow" - Table = "table" - Database = "database" - View = "view" + RunExplainData = "RunExplainData" + CreateRefreshMetrics = "CreateRefreshMetrics" + SaveAs = "SaveAs" class PermissionsRule(object): @@ -52,6 +47,11 @@ def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) - self.grantee = grantee self.capabilities = capabilities + def __str__(self): + return "".format(self.grantee, self.capabilities) + + __repr__ = __str__ + @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py deleted file mode 100644 index e1744766d..000000000 --- a/tableauserverclient/models/personal_access_token_auth.py +++ /dev/null @@ -1,17 +0,0 @@ -class PersonalAccessTokenAuth(object): - def __init__(self, token_name, personal_access_token, site_id=None): - self.token_name = token_name - self.personal_access_token = personal_access_token - self.site_id = site_id if site_id is not None else "" - # Personal Access Tokens doesn't support impersonation. - self.user_id_to_impersonate = None - - @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/project_item.py b/tableauserverclient/models/project_item.py index 177b3e016..9237d134e 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,16 @@ +import logging import xml.etree.ElementTree as ET -from typing import List, Optional from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty +from typing import List, Optional + + +from typing import List, Optional, TYPE_CHECKING + class ProjectItem(object): class ContentPermissions: @@ -31,6 +36,7 @@ def __init__( self._default_workbook_permissions = None self._default_datasource_permissions = None self._default_flow_permissions = None + self._default_lens_permissions = None @property def content_permissions(self): @@ -69,6 +75,13 @@ def default_flow_permissions(self): raise UnpopulatedPropertyError(error) return self._default_flow_permissions() + @property + def default_lens_permissions(self): + if self._default_lens_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_lens_permissions() + @property def id(self) -> Optional[str]: return self._id @@ -126,11 +139,15 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, - "_default_{content}_permissions".format(content=content_type), + attr, permissions, ) + fetch_call = getattr(self, attr) + logging.getLogger().info({"type": attr, "value": fetch_call()}) + return fetch_call() @classmethod def from_response(cls, resp, ns) -> List["ProjectItem"]: diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 48d2ab56a..6fc6b0c22 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -3,6 +3,11 @@ def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name + def __str__(self): + return "".format(self._id, self._tag_name) + + __repr__ = __str__ + @property def id(self): return self._id diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index e9760cbee..f373a84ab 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,41 +1,70 @@ -class TableauAuth(object): +class Credentials: + def __init__(self, site_id=None, user_id_to_impersonate=None): + self.site_id = site_id or "" + self.user_id_to_impersonate = user_id_to_impersonate or None + + @property + def credentials(self): + credentials = "Credentials can be username/password, Personal Access Token, or JWT" + +"This method returns values to set as an attribute on the credentials element of the request" + + def __repr__(self): + display = "All Credentials types must have a debug display that does not print secrets" + + +def deprecate_site_attribute(): + import warnings + + warnings.warn( + "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", + DeprecationWarning, + ) + + +# The traditional auth type: username/password +class TableauAuth(Credentials): def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): if site is not None: - import warnings - - warnings.warn( - "TableauAuth(..., site=...) is deprecated, " "please use TableauAuth(..., site_id=...) instead.", - DeprecationWarning, - ) + deprecate_site_attribute() site_id = site + super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") - - self.user_id_to_impersonate = user_id_to_impersonate self.password = password - self.site_id = site_id if site_id is not None else "" self.username = username @property - def site(self): - import warnings + def credentials(self): + return {"name": self.username, "password": self.password} - warnings.warn( - "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", - DeprecationWarning, - ) + def __repr__(self): + return "".format(self.username, "") + + @property + def site(self): + deprecate_site_attribute() return self.site_id @site.setter def site(self, value): - import warnings - - warnings.warn( - "TableauAuth.site is deprecated, use TableauAuth.site_id instead.", - DeprecationWarning, - ) + deprecate_site_attribute() self.site_id = value + +class PersonalAccessTokenAuth(Credentials): + def __init__(self, token_name, personal_access_token, site_id=None): + super().__init__(site_id=site_id) + self.token_name = token_name + self.personal_access_token = personal_access_token + @property def credentials(self): - return {"name": self.username, "password": self.password} + return { + "personalAccessTokenName": self.token_name, + "personalAccessTokenSecret": self.personal_access_token, + } + + def __repr__(self): + return "".format( + self.token_name, self.personal_access_token[:2] + "...", self.site_id + ) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py new file mode 100644 index 000000000..feaf02873 --- /dev/null +++ b/tableauserverclient/models/tableau_types.py @@ -0,0 +1,31 @@ +from tableauserverclient.models.database_item import DatabaseItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem + +from typing import Union + + +class Resource: + Database = "database" + Datasource = "datasource" + Flow = "flow" + Lens = "lens" + Project = "project" + Table = "table" + View = "view" + Workbook = "workbook" + + +# resource types that have permissions, can be renamed, etc +TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] + + +def plural_type(content_type: Resource) -> str: + if content_type == Resource.Lens: + return "lenses" + else: + return "{}s".format(content_type) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index b94f33725..f60e72951 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,3 +1,4 @@ +from datetime import datetime import xml.etree.ElementTree as ET from datetime import datetime from typing import Dict, List, Optional, TYPE_CHECKING @@ -13,6 +14,11 @@ from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime +if TYPE_CHECKING: + from ..server.pager import Pager + +from typing import Dict, List, Optional, TYPE_CHECKING + if TYPE_CHECKING: from ..server.pager import Pager diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 146f21077..01635349b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,5 +1,5 @@ import copy -from typing import Callable, Iterable, List, Optional, Set, TYPE_CHECKING +from typing import Callable, Generator, Iterator, List, Optional, Set, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -24,8 +24,8 @@ def __init__(self) -> None: self._preview_image: Optional[Callable[[], bytes]] = None self._project_id: Optional[str] = None self._pdf: Optional[Callable[[], bytes]] = None - self._csv: Optional[Callable[[], Iterable[bytes]]] = None - self._excel: Optional[Callable[[], Iterable[bytes]]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None + self._excel: Optional[Callable[[], Iterator[bytes]]] = None self._total_views: Optional[int] = None self._sheet_type: Optional[str] = None self._updated_at: Optional["datetime"] = None @@ -94,14 +94,14 @@ def pdf(self) -> bytes: return self._pdf() @property - def csv(self) -> Iterable[bytes]: + def csv(self) -> Iterator[bytes]: if self._csv is None: error = "View item must be populated with its csv first." raise UnpopulatedPropertyError(error) return self._csv() @property - def excel(self) -> Iterable[bytes]: + def excel(self) -> Iterator[bytes]: if self._excel is None: error = "View item must be populated with its excel first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 949970ced..0d18e770d 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -29,6 +29,7 @@ from .connection_item import ConnectionItem from .permissions_item import PermissionsRule import datetime + from .revision_item import RevisionItem class WorkbookItem(object): @@ -124,7 +125,6 @@ def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable def project_id(self, value: str): self._project_id = value diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 5d1fb961b..cb680d914 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -36,6 +36,9 @@ ViewItem, WebhookItem, WorkbookItem, + TableauItem, + Resource, + plural_type, ) from .endpoint import ( Auth, @@ -59,3 +62,4 @@ from .server import Server from .pager import Pager from .exceptions import NotSignedInError +from ..helpers import * diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 255b7b7a3..1fab7ac4b 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -5,7 +5,7 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Permission +from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Resource logger = logging.getLogger("tableau.endpoint.databases") @@ -16,7 +16,7 @@ def __init__(self, parent_srv): self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) - self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, "database") + self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property def baseurl(self): @@ -108,15 +108,15 @@ def delete_permission(self, item, rules): @api(version="3.5") def populate_table_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Table) + self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Permission.Resource.Table) + return self._default_permissions.update_default_permissions(item, Resource.Table) @api(version="3.5") def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Permission.Resource.Table) + self._default_permissions.delete_default_permissions(item, Resource.Table) @api(version="3.5") def populate_dqw(self, item): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index cb5600938..022523aa4 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -339,7 +339,7 @@ def update_hyper_data( *, request_id: str, actions: Sequence[Mapping], - payload: Optional[FilePath] = None + payload: Optional[FilePath] = None, ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 6e54d02c7..66fc23d49 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -3,56 +3,53 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError from .. import RequestFactory -from ...models import PermissionsRule - -logger = logging.getLogger(__name__) - +from ...models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: - from ...models import ( - DatasourceItem, - FlowItem, - ProjectItem, - ViewItem, - WorkbookItem, - ) - from ..server import Server from ..request_options import RequestOptions - TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] +logger = logging.getLogger(__name__) + +# these are the only two items that can hold default permissions for another type +BaseItem = Union[DatabaseItem, ProjectItem] class _DefaultPermissionsEndpoint(Endpoint): - """Adds default-permission model to another endpoint + """Adds default-permission model to an existing database or project - Tableau default-permissions model applies only to databases and projects - and then takes an object type in the uri to set the defaults. - This class is meant to be instantated inside a parent endpoint which + Tableau default-permissions model takes an object type in the uri to set the defaults. + This class is meant to be instantiated inside a parent endpoint which has these supported endpoints """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) - # owner_baseurl is the baseurl of the parent. The MUST be a lambda - # since we don't know the full site URL until we sign in. If - # populated without, we will get a sign-in error + # owner_baseurl is the baseurl of the parent, a project or database. + # It MUST be a lambda since we don't know the full site URL until we sign in. + # If populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl + def __str__(self): + return "".format(self.owner_baseurl()) + + __repr__ = __str__ + def update_default_permissions( - self, resource: "TableauItem", permissions: Sequence[PermissionsRule], content_type: str + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, content_type + "s") + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}".format(resource.id)) + logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(permissions) return permissions - def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRule, content_type: str) -> None: + def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -60,7 +57,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu "{content_type}/{grantee_type}/{grantee_id}/{cap}/{mode}".format( baseurl=self.owner_baseurl(), content_id=resource.id, - content_type=content_type + "s", + content_type=plural_type(content_type), grantee_type=rule.grantee.tag_name + "s", grantee_id=rule.grantee.id, cap=capability, @@ -68,7 +65,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu ) ) - logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) @@ -76,7 +73,7 @@ def delete_default_permission(self, resource: "TableauItem", rule: PermissionsRu "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) ) - def populate_default_permissions(self, item: "ProjectItem", content_type: str) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -85,13 +82,13 @@ def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated {0} permissions for item (ID: {1})".format(item.id, content_type)) + logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) def _get_default_permissions( - self, item: "TableauItem", content_type: str, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, content_type + "s") + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - + logger.info({"content_type": content_type, "permissions": permissions}) return permissions diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8fdb74751..0acc978d2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,7 +1,9 @@ +import requests import logging from distutils.version import LooseVersion as Version from functools import wraps from xml.etree.ElementTree import ParseError +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from .exceptions import ( ServerResponseError, @@ -10,6 +12,7 @@ EndpointUnavailableError, ) from ..query import QuerySet +from ... import helpers logger = logging.getLogger("tableau.endpoint") @@ -18,9 +21,13 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" +if TYPE_CHECKING: + from ..server import Server + from requests import Response + class Endpoint(object): - def __init__(self, parent_srv): + def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @staticmethod @@ -33,29 +40,18 @@ def _make_common_headers(auth_token, content_type): return headers - @staticmethod - def _safe_to_log(server_response): - """Checks if the server_response content is not xml (eg binary image or zip) - and replaces it with a constant - """ - ALLOWED_CONTENT_TYPES = ("application/xml", "application/xml;charset=utf-8") - if server_response.headers.get("Content-Type", None) not in ALLOWED_CONTENT_TYPES: - return "[Truncated File Contents]" - else: - return server_response.content - def _make_request( self, - method, - url, - content=None, - auth_token=None, - content_type=None, - parameters=None, - ): + method: Callable[..., "Response"], + url: str, + content: Optional[bytes] = None, + auth_token: Optional[str] = None, + content_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + ) -> "Response": parameters = parameters or {} parameters.update(self.parent_srv.http_options) - if not "headers" in parameters: + if "headers" not in parameters: parameters["headers"] = {} parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) @@ -64,29 +60,29 @@ def _make_request( logger.debug("request {}, url: {}".format(method.__name__, url)) if content: - logger.debug("request content: {}".format(content[:1000])) + logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) server_response = method(url, **parameters) - self.parent_srv._namespace.detect(server_response.content) self._check_status(server_response) - # This check is to determine if the response is a text response (xml or otherwise) - # so that we do not attempt to log bytes and other binary data. - if len(server_response.content) > 0 and server_response.encoding: - logger.debug( - "Server response from {0}:\n\t{1}".format(url, server_response.content.decode(server_response.encoding)) - ) + loggable_response = self.log_response_safely(server_response) + logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + + if content_type == "application/xml": + self.parent_srv._namespace.detect(server_response.content) + return server_response def _check_status(self, server_response): if server_response.status_code >= 500: raise InternalServerError(server_response) elif server_response.status_code not in Success_codes: + # todo: is an error reliably of content-type application/xml? try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) except ParseError: # This will happen if we get a non-success HTTP code that - # doesn't return an xml error object (like metadata endpoints) + # doesn't return an xml error object (like metadata endpoints or 503 pages) # we convert this to a better exception and pass through the raw # response body raise NonXMLResponseError(server_response.content) @@ -94,6 +90,21 @@ def _check_status(self, server_response): # anything else re-raise here raise + def log_response_safely(self, server_response: requests.Response) -> str: + # Checking the content type header prevents eager evaluation of streaming requests. + content_type = server_response.headers.get("Content-Type") + + # Response.content is a property. Calling it will load the entire response into memory. Checking if the + # content-type is an octet-stream accomplishes the same goal without eagerly loading content. + # This check is to determine if the response is a text response (xml or otherwise) + # so that we do not attempt to log bytes and other binary data. + loggable_response = "Content type {}".format(content_type) + if content_type == "application/octet-stream": + loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + elif server_response.encoding and len(server_response.content) > 0: + loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) + return loggable_response + def get_unauthenticated_request(self, url): return self._make_request(self.parent_srv.session.get, url) @@ -118,7 +129,7 @@ def delete_request(self, url): # We don't return anything for a delete self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) - def put_request(self, url, xml_request=None, content_type="text/xml", parameters=None): + def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.put, url, @@ -128,7 +139,7 @@ def put_request(self, url, xml_request=None, content_type="text/xml", parameters parameters=parameters, ) - def post_request(self, url, xml_request, content_type="text/xml", parameters=None): + def post_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.post, url, @@ -138,7 +149,7 @@ def post_request(self, url, xml_request, content_type="text/xml", parameters=Non parameters=parameters, ) - def patch_request(self, url, xml_request, content_type="text/xml", parameters=None): + def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, parameters=None): return self._make_request( self.parent_srv.session.patch, url, diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10a1d9fac..f7c2f9f13 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -5,17 +5,15 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError +from ...models import TableauItem from typing import Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) if TYPE_CHECKING: - from ...models import DatasourceItem, ProjectItem, WorkbookItem, ViewItem from ..server import Server from ..request_options import RequestOptions -TableauItem = Union["DatasourceItem", "ProjectItem", "WorkbookItem", "ViewItem"] - class _PermissionsEndpoint(Endpoint): """Adds permission model to another endpoint @@ -34,12 +32,15 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No # populated without, we will get a sign-in error self.owner_baseurl = owner_baseurl + def __str__(self): + return "".format(self.owner_baseurl) + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}".format(resource.id)) + logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) return permissions @@ -62,7 +63,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capabilty {1}".format(mode, capability)) + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) @@ -85,5 +86,6 @@ def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = No url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) + logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index b21ba3682..e268d2011 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -4,9 +4,7 @@ from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Permission - -logger = logging.getLogger("tableau.endpoint.projects") +from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING @@ -14,6 +12,8 @@ from ..server import Server from ..request_options import RequestOptions +logger = logging.getLogger("tableau.endpoint.projects") + class Projects(QuerysetEndpoint): def __init__(self, parent_srv: "Server") -> None: @@ -93,36 +93,48 @@ def delete_permission(self, item, rules): @api(version="2.1") def populate_workbook_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Workbook) + self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") def populate_datasource_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Datasource) + self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.4") def populate_flow_default_permissions(self, item): - self._default_permissions.populate_default_permissions(item, Permission.Resource.Flow) + self._default_permissions.populate_default_permissions(item, Resource.Flow) + + @api(version="3.4") + def populate_lens_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="2.1") def update_workbook_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Workbook) + return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") def update_datasource_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Datasource) + return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.4") def update_flow_default_permissions(self, item, rules): - return self._default_permissions.update_default_permissions(item, rules, Permission.Resource.Flow) + return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) + + @api(version="3.4") + def update_lens_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Workbook) + self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") def delete_datasource_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Datasource) + self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.4") def delete_flow_default_permissions(self, item, rule): - self._default_permissions.delete_default_permission(item, rule, Permission.Resource.Flow) + self._default_permissions.delete_default_permission(item, rule, Resource.Flow) + + @api(version="3.4") + def delete_lens_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Lens) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 339952704..f147c79ae 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -53,7 +53,7 @@ def get_by_id(self, task_id): @api(version="2.6") def run(self, task_item): if not task_item.id: - error = "User item missing ID." + error = "Task item missing ID." raise MissingRequiredFieldError(error) url = "{0}/{1}/{2}/runNow".format( @@ -63,7 +63,7 @@ def run(self, task_item): ) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) - return server_response.content + return server_response.content # Todo add typing # Delete 1 task by id @api(version="3.6") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a1984d5d6..738364cd7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Tuple +from typing import List, Optional, Tuple from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError @@ -64,11 +64,13 @@ def update(self, user_item: UserItem, password: str = None) -> UserItem: # Delete 1 user by id @api(version="2.0") - def remove(self, user_id: str) -> None: + def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, user_id) + if map_assets_to is not None: + url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) logger.info("Removed single user (ID: {0})".format(user_id)) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index cb652fbc0..67e66a81f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -9,7 +9,7 @@ logger = logging.getLogger("tableau.endpoint.views") -from typing import Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions @@ -119,12 +119,11 @@ def csv_fetcher(): view_item._set_csv(csv_fetcher) logger.info("Populated csv for view (ID: {0})".format(view_item.id)) - def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - csv = server_response.iter_content(1024) - return csv + yield from server_response.iter_content(1024) @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: @@ -138,12 +137,11 @@ def excel_fetcher(): view_item._set_excel(excel_fetcher) logger.info("Populated excel for view (ID: {0})".format(view_item.id)) - def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterable[bytes]: + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: - excel = server_response.iter_content(1024) - return excel + yield from server_response.iter_content(1024) @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 901d0e62a..4d7a4a2b5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -16,6 +16,7 @@ from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError +from ...helpers import redact_xml from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem @@ -441,7 +442,7 @@ def publish( connections=connections, hidden_views=hidden_views, ) - logger.debug("Request xml: {0} ".format(xml_request[:1000])) + logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) # Send the publishing request to server try: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 64a7107aa..729447822 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -71,7 +71,8 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = None - self.request_options.pagenumber = max(1, math.ceil(k / size)) + # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 + self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] else: # If k is unreasonable, raise an IndexError. diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7e4038979..fc00ca085 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -16,6 +16,16 @@ from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem from ..models import WebhookItem +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Iterable + +if TYPE_CHECKING: + from ..models import SubscriptionItem + from ..models import DataAlertItem + from ..models import FlowItem + from ..models import ConnectionItem + from ..models import SiteItem + from ..models import ProjectItem + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -39,6 +49,8 @@ def wrapper(self, *args, **kwargs): def _add_connections_element(connections_element, connection): connection_element = ET.SubElement(connections_element, "connection") + if not connection.server_address: + raise ValueError("Connection must have a server address") connection_element.attrib["serverAddress"] = connection.server_address if connection.server_port: connection_element.attrib["serverPort"] = connection.server_port @@ -55,6 +67,8 @@ def _add_hiddenview_element(views_element, view_name): def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") + if not connection_credentials.password or not connection_credentials.name: + raise ValueError("Connection Credentials must have a name and password") credentials_element.attrib["name"] = connection_credentials.name credentials_element.attrib["password"] = connection_credentials.password credentials_element.attrib["embed"] = "true" if connection_credentials.embed else "false" @@ -232,7 +246,7 @@ def add_req(self, dqw_item): return ET.tostring(xml_request) - def update_req(self, database_item): + def update_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -877,7 +891,6 @@ def _generate_xml( views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) - return ET.tostring(xml_request) def update_req(self, workbook_item): @@ -950,9 +963,9 @@ def embedded_extract_req(self, xml_request, include_all=True, datasources=None): list_element = ET.SubElement(xml_request, "datasources") if include_all: list_element.attrib["includeAll"] = "true" - else: + elif datasources: for datasource_item in datasources: - datasource_element = list_element.SubElement(xml_request, "datasource") + datasource_element = ET.SubElement(list_element, "datasource") datasource_element.attrib["id"] = datasource_item.id diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 36ffccd8e..4462ba786 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -46,10 +46,13 @@ class Field: OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" OwnerName = "ownerName" + ParentProjectId = "parentProjectId" Progress = "progress" ProjectName = "projectName" PublishSamples = "publishSamples" SiteRole = "siteRole" + StartedAt = "startedAt" + Status = "status" Subtitle = "subtitle" Tags = "tags" Title = "title" diff --git a/test/assets/job_get_by_id_failed_workbook.xml b/test/assets/job_get_by_id_failed_workbook.xml new file mode 100644 index 000000000..bf81d896e --- /dev/null +++ b/test/assets/job_get_by_id_failed_workbook.xml @@ -0,0 +1,9 @@ + + + + + + java.lang.RuntimeException: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Login failed for user.\nIntegrated authentication failed. + + + diff --git a/test/assets/queryset_slicing_page_1.xml b/test/assets/queryset_slicing_page_1.xml new file mode 100644 index 000000000..be3df91f8 --- /dev/null +++ b/test/assets/queryset_slicing_page_1.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/queryset_slicing_page_2.xml b/test/assets/queryset_slicing_page_2.xml new file mode 100644 index 000000000..058bbd5c0 --- /dev/null +++ b/test/assets/queryset_slicing_page_2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/request_factory/__init__.py b/test/request_factory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/request_factory/test_datasource_requests.py b/test/request_factory/test_datasource_requests.py new file mode 100644 index 000000000..75bb535d5 --- /dev/null +++ b/test/request_factory/test_datasource_requests.py @@ -0,0 +1,15 @@ +import unittest +import tableauserverclient as TSC +import tableauserverclient.server.request_factory as TSC_RF +from tableauserverclient import DatasourceItem + + +class DatasourceRequestTests(unittest.TestCase): + def test_generate_xml(self): + datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name") + datasource_item.name = "a ds" + datasource_item.description = "described" + datasource_item.use_remote_query_agent = False + datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled + datasource_item.project_id = "testval" + TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item) diff --git a/test/request_factory/test_workbook_requests.py b/test/request_factory/test_workbook_requests.py new file mode 100644 index 000000000..332b6defa --- /dev/null +++ b/test/request_factory/test_workbook_requests.py @@ -0,0 +1,55 @@ +import unittest +import tableauserverclient as TSC +import tableauserverclient.server.request_factory as TSC_RF +from tableauserverclient.helpers.strings import redact_xml +import pytest +import sys + + +class WorkbookRequestTests(unittest.TestCase): + def test_embedded_extract_req(self): + include_all = True + embedded_datasources = None + xml_result = TSC_RF.RequestFactory.Workbook.embedded_extract_req(include_all, embedded_datasources) + + def test_generate_xml(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item) + + def test_generate_xml_invalid_connection(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + with self.assertRaises(ValueError): + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + + def test_generate_xml_invalid_connection_credentials(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "password") + creds.name = None + conn.connection_credentials = creds + with self.assertRaises(ValueError): + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + + def test_generate_xml_valid_connection_credentials(self): + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + assert request.find(b"DELETEME") > 0 + + def test_redact_passwords_in_xml(self): + if sys.version_info < (3, 7): + pytest.skip("Redaction is only implemented for 3.7+.") + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + redacted = redact_xml(request) + assert request.find(b"DELETEME") > 0, request + assert redacted.find(b"DELETEME") == -1, redacted diff --git a/test/test_dqw.py b/test/test_dqw.py new file mode 100644 index 000000000..6d1219f66 --- /dev/null +++ b/test/test_dqw.py @@ -0,0 +1,11 @@ +import unittest +import tableauserverclient as TSC + + +class DQWTests(unittest.TestCase): + def test_existence(self): + dqw: TSC.DQWItem = TSC.DQWItem() + dqw.message = "message" + dqw.warning_type = TSC.DQWItem.WarningType.STALE + dqw.active = True + dqw.severe = True diff --git a/test/test_endpoint.py b/test/test_endpoint.py new file mode 100644 index 000000000..e583a9188 --- /dev/null +++ b/test/test_endpoint.py @@ -0,0 +1,40 @@ +from pathlib import Path +import unittest + +import tableauserverclient as TSC + +import requests_mock + +ASSETS = Path(__file__).parent / "assets" + + +class TestEndpoint(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test/", use_server_version=False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return super().setUp() + + def test_get_request_stream(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url, headers={"Content-Type": "application/octet-stream"}) + response = endpoint.get_request(url, parameters={"stream": True}) + + self.assertFalse(response._content_consumed) + + def test_binary_log_truncated(self): + class FakeResponse(object): + + headers = {"Content-Type": "application/octet-stream"} + content = b"\x1337" * 1000 + status_code = 200 + + endpoint = TSC.server.Endpoint(self.server) + server_response = FakeResponse() + log = endpoint.log_response_safely(server_response) + self.assertTrue(log.find("[Truncated File Contents]") > 0, log) diff --git a/test/test_job.py b/test/test_job.py index 6daa16afa..19a93e808 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -9,13 +9,12 @@ from tableauserverclient.server.endpoint.exceptions import JobFailedException from ._utils import read_xml_asset, mocked_time -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - GET_XML = "job_get.xml" GET_BY_ID_XML = "job_get_by_id.xml" GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" +GET_BY_ID_WORKBOOK = "job_get_by_id_failed_workbook.xml" class JobTests(unittest.TestCase): @@ -103,3 +102,19 @@ def test_wait_for_job_timeout(self) -> None: m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) + + def test_get_job_datasource_id(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.datasource_id, "03b9fbec-81f6-4160-ae49-5f9f6d412758") + + def test_get_job_workbook_id(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 58d6329db..772704f69 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -6,7 +6,7 @@ import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory -from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.helpers.strings import redact_xml from tableauserverclient.filesys_helpers import to_filename, make_download_path @@ -16,19 +16,6 @@ def test_empty_request_works(self): self.assertEqual(b"", result) -class BugFix273(unittest.TestCase): - def test_binary_log_truncated(self): - class FakeResponse(object): - - headers = {"Content-Type": "application/octet-stream"} - content = b"\x1337" * 1000 - status_code = 200 - - server_response = FakeResponse() - - self.assertEqual(Endpoint._safe_to_log(server_response), "[Truncated File Contents]") - - class FileSysHelpers(unittest.TestCase): def test_to_filename(self): invalid = [ @@ -60,3 +47,41 @@ def test_make_download_path(self): with mock.patch("os.path.isdir") as mocked_isdir: mocked_isdir.return_value = True self.assertEqual("/root/folder/file.ext", make_download_path(*has_file_path_folder)) + + +class LoggingTest(unittest.TestCase): + def test_redact_password_string(self): + redacted = redact_xml( + "this is password: my_super_secret_passphrase_which_nobody_should_ever_see password: value" + ) + assert redacted.find("value") == -1 + assert redacted.find("secret") == -1 + assert redacted.find("ever_see") == -1 + assert redacted.find("my_super_secret_passphrase_which_nobody_should_ever_see") == -1 + + def test_redact_password_bytes(self): + redacted = redact_xml( + b"" + ) + assert redacted.find(b"value") == -1 + assert redacted.find(b"secret") == -1 + + def test_redact_password_with_special_char(self): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value") == -1 + + def test_redact_password_not_xml(self): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + + def test_redact_password_really_not_xml(self): + redacted = redact_xml( + "value='this is a nondescript text line which is public' password='my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value and then a cookie " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + assert redacted.find("passphrase") == -1, redacted + assert redacted.find("cookie") == -1, redacted diff --git a/test/test_request_option.py b/test/test_request_option.py index ed8d55bb0..9dacbe033 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import re import unittest @@ -6,7 +7,7 @@ import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" PAGINATION_XML = os.path.join(TEST_ASSET_DIR, "request_option_pagination.xml") PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") @@ -15,10 +16,12 @@ FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") +SLICING_QUERYSET_PAGE_1 = TEST_ASSET_DIR / "queryset_slicing_page_1.xml" +SLICING_QUERYSET_PAGE_2 = TEST_ASSET_DIR / "queryset_slicing_page_2.xml" class RequestOptionTests(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.server = TSC.Server("http://test", False) # Fake signin @@ -28,7 +31,7 @@ def setUp(self): self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) - def test_pagination(self): + def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -41,7 +44,7 @@ def test_pagination(self): self.assertEqual(33, pagination_item.total_available) self.assertEqual(10, len(all_views)) - def test_page_number(self): + def test_page_number(self) -> None: with open(PAGE_NUMBER_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -54,7 +57,7 @@ def test_page_number(self): self.assertEqual(210, pagination_item.total_available) self.assertEqual(10, len(all_views)) - def test_page_size(self): + def test_page_size(self) -> None: with open(PAGE_SIZE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -67,7 +70,7 @@ def test_page_size(self): self.assertEqual(33, pagination_item.total_available) self.assertEqual(5, len(all_views)) - def test_filter_equals(self): + def test_filter_equals(self) -> None: with open(FILTER_EQUALS, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -82,7 +85,7 @@ def test_filter_equals(self): self.assertEqual("RESTAPISample", matching_workbooks[0].name) self.assertEqual("RESTAPISample", matching_workbooks[1].name) - def test_filter_equals_shorthand(self): + def test_filter_equals_shorthand(self) -> None: with open(FILTER_EQUALS, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -93,7 +96,7 @@ def test_filter_equals_shorthand(self): self.assertEqual("RESTAPISample", matching_workbooks[0].name) self.assertEqual("RESTAPISample", matching_workbooks[1].name) - def test_filter_tags_in(self): + def test_filter_tags_in(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -111,7 +114,7 @@ def test_filter_tags_in(self): self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) - def test_filter_tags_in_shorthand(self): + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -123,11 +126,11 @@ def test_filter_tags_in_shorthand(self): self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) - def test_invalid_shorthand_option(self): + def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): self.server.workbooks.filter(nonexistant__in=["sample", "safari"]) - def test_multiple_filter_options(self): + def test_multiple_filter_options(self) -> None: with open(FILTER_MULTIPLE, "rb") as f: response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times @@ -153,7 +156,7 @@ def test_multiple_filter_options(self): self.assertEqual(3, pagination_item.total_available) # Test req_options if url already has query params - def test_double_query_params(self): + def test_double_query_params(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views?queryParamExists=true" @@ -170,7 +173,7 @@ def test_double_query_params(self): self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) # Test req_options for versions below 3.7 - def test_filter_sort_legacy(self): + def test_filter_sort_legacy(self) -> None: self.server.version = "3.6" with requests_mock.mock() as m: m.get(requests_mock.ANY) @@ -187,7 +190,7 @@ def test_filter_sort_legacy(self): self.assertTrue(re.search("filter=tags:in:%5bstocks,market%5d", resp.request.query)) self.assertTrue(re.search("sort=name:asc", resp.request.query)) - def test_vf(self): + def test_vf(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" @@ -202,7 +205,7 @@ def test_vf(self): self.assertTrue(re.search("type=tabloid", resp.request.query)) # Test req_options for versions beloe 3.7 - def test_vf_legacy(self): + def test_vf_legacy(self) -> None: self.server.version = "3.6" with requests_mock.mock() as m: m.get(requests_mock.ANY) @@ -217,7 +220,7 @@ def test_vf_legacy(self): self.assertTrue(re.search("vf_name2\\$=value2", resp.request.query)) self.assertTrue(re.search("type=tabloid", resp.request.query)) - def test_all_fields(self): + def test_all_fields(self) -> None: with requests_mock.mock() as m: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" @@ -227,7 +230,7 @@ def test_all_fields(self): resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) - def test_multiple_filter_options_shorthand(self): + def test_multiple_filter_options_shorthand(self) -> None: with open(FILTER_MULTIPLE, "rb") as f: response_xml = f.read().decode("utf-8") # To ensure that this is deterministic, run this a few times @@ -246,7 +249,7 @@ def test_multiple_filter_options_shorthand(self): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) - def test_slicing_queryset(self): + def test_slicing_queryset(self) -> None: with open(SLICING_QUERYSET, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -270,6 +273,16 @@ def test_slicing_queryset(self): with self.assertRaises(IndexError): all_views[100] - def test_queryset_filter_args_error(self): + def test_slicing_queryset_multi_page(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl + "/views?pageNumber=1", text=SLICING_QUERYSET_PAGE_1.read_text()) + m.get(self.baseurl + "/views?pageNumber=2", text=SLICING_QUERYSET_PAGE_2.read_text()) + sliced_views = self.server.views.all()[9:12] + + self.assertEqual(sliced_views[0].id, "2e6d6c81-da71-4b41-892c-ba80d4e7a6d0") + self.assertEqual(sliced_views[1].id, "47ffcb8e-3f7a-4ecf-8ab3-605da9febe20") + self.assertEqual(sliced_views[2].id, "6757fea8-0aa9-4160-a87c-9be27b1d1c8c") + + def test_queryset_filter_args_error(self) -> None: with self.assertRaises(RuntimeError): workbooks = self.server.workbooks.filter("argument") diff --git a/test/test_user.py b/test/test_user.py index 6ba8ff7f2..b8fe32388 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -111,6 +111,16 @@ def test_remove(self) -> None: m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") + def test_remove_with_replacement(self) -> None: + with requests_mock.mock() as m: + m.delete( + self.baseurl + + "/dd2239f6-ddf1-4107-981a-4cf94e415794" + + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced", + status_code=204, + ) + self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced") + def test_remove_missing_id(self) -> None: self.assertRaises(ValueError, self.server.users.remove, "") diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index d45899e2d..fc6423564 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -4,12 +4,6 @@ class WorkbookModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.WorkbookItem, None) - workbook = TSC.WorkbookItem("10") - with self.assertRaises(ValueError): - workbook.project_id = None - def test_invalid_show_tabs(self): workbook = TSC.WorkbookItem("10") with self.assertRaises(ValueError): From 265a4bf6735d47d3bb487283543a0628a7505900 Mon Sep 17 00:00:00 2001 From: Tim <50115603+bossenti@users.noreply.github.com> Date: Sat, 10 Sep 2022 08:26:15 +0200 Subject: [PATCH 281/567] replace deprecated Version class from distutils with packaging.version (#1105) --- tableauserverclient/server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4522bc272..e35514474 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,7 +1,7 @@ -from distutils.version import LooseVersion as Version import urllib3 import requests from defusedxml.ElementTree import fromstring +from packaging.version import Version from .endpoint import ( Sites, @@ -38,7 +38,7 @@ import requests -from distutils.version import LooseVersion as Version +from packaging.version import Version _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", From 3c021c084f38fd9cf619a583f7ba586f4bb3b219 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 12 Sep 2022 15:14:44 -0700 Subject: [PATCH 282/567] prepare release 0.20 (#1107) * Datasources: Use explicit None identity check for datasource updates (#1099) (Resolves #1062 - cannot set empty password) * Projects: add publish-samples option to create/update project * Workbooks: fix workbook.delete_extract, add workbook pdf download, make project_id nullable to support "Personal Space", Remove vf support from populate_excel, make hidden views an attribute of Workbooks and deprecate hidden_views flag in publish request * Schedules: add get_by_id method * Users: Reassign content on user removal, add user import logic * Jobs: Add Status, ParentProjectId and StartedAt filters, Extract refreshable item IDs from job XML response * Sites: Add version awareness to site create/update methods: Update sites requests for Breaking change in 3.10: flowsEnabled removed, flowsEditingEnabled and flowsSchedulingEnabled added ,Allow setting site user_quota to None if tiered licenses exist * Do not eagerly fetch content when a stream was requested * create single Credentials class (#1032), Included redacted print methods for each credential type * on init set use_server_version = False so that we don't try and contact the server before people finish setting certs * add client version/debug header * Logging: log RequestOptions params (#1070), add redaction method to remove passwords when logging requests and responses, which can contain embedded credentials, log the url of the request that got an error in the response. * fix filter for python 3, remove support for python 3.6 (add python version enforcement in setup.py) * Fix slicing logic, add tests for queryset slicing crossing a page, add support for len magic method to queryset * Add type hints for workbook and data source revisions, data alerts, Favorites, Flows, groups, permissions, projects, sites, subscriptions, Users, webhooks * Samples: fix export sample, delete redundant samples (export_wb, download_view_image), add user import sample, default permissions sample * add publish to pypi actio, enable Black for CI, consolidate config files into pyproject.toml co-authored-by: Amar Yadav Co-authored-by: Jac Co-authored-by: Stephen Mitchell Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: Tyler Doyle Co-authored-by: bcmyguest1 <49045013+bcmyguest1@users.noreply.github.com> --- .github/workflows/publish-pypi.yml | 9 +- .github/workflows/run-tests.yml | 4 - MANIFEST.in | 12 +- contributing.md | 5 +- pyproject.toml | 42 +++- samples/create_group.py | 48 +++- samples/explore_site.py | 83 +++++++ samples/list.py | 5 +- samples/online_users.csv | 2 + setup.cfg | 19 +- setup.py | 49 +--- smoke/__init__.py | 0 tableauserverclient/__init__.py | 66 +++-- tableauserverclient/datetime_helpers.py | 4 +- tableauserverclient/models/connection_item.py | 46 ++-- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/project_item.py | 3 - tableauserverclient/models/site_item.py | 32 ++- tableauserverclient/models/user_item.py | 180 +++++++++++++- tableauserverclient/server/__init__.py | 2 +- .../server/endpoint/auth_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 28 ++- .../server/endpoint/exceptions.py | 50 ++-- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/server_info_endpoint.py | 1 + .../server/endpoint/sites_endpoint.py | 30 ++- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 56 ++++- .../server/endpoint/views_endpoint.py | 12 +- tableauserverclient/server/query.py | 23 +- tableauserverclient/server/request_factory.py | 76 ++++-- tableauserverclient/server/request_options.py | 27 +++ tableauserverclient/server/server.py | 39 ++- test/assets/Data/user_details.csv | 1 + test/assets/Data/usernames.csv | 7 + test/assets/site_get_by_id.xml | 4 +- test/assets/site_get_by_name.xml | 4 +- test/assets/site_update.xml | 4 +- test/test_group.py | 10 +- test/test_project.py | 7 +- test/test_requests.py | 1 + test/test_site.py | 18 +- test/test_user.py | 24 ++ test/test_user_model.py | 114 +++++++++ versioneer.py | 226 ++++++++++-------- 45 files changed, 1005 insertions(+), 386 deletions(-) create mode 100644 samples/explore_site.py create mode 100644 samples/online_users.csv delete mode 100644 smoke/__init__.py create mode 100644 test/assets/Data/user_details.csv create mode 100644 test/assets/Data/usernames.csv diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 2b3b8fa3e..9b4e842ee 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -6,8 +6,8 @@ name: Publish to PyPi on: workflow_dispatch: push: - branches: - - master + tags: + - 'v*.*.*' jobs: build-n-publish: @@ -19,12 +19,13 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.7 - name: Build dist files run: | python -m pip install --upgrade pip pip install -e .[test] - python setup.py sdist --formats=gztar + python setup.py sdist --formats=gztar bdist_wheel + git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 60a209b61..b83af5a4b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -29,7 +29,3 @@ jobs: if: always() run: | pytest test - - - name: Run Mypy tests - run: | - mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test diff --git a/MANIFEST.in b/MANIFEST.in index c9bb30ee7..9b7512fb9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,13 +1,14 @@ -include versioneer.py -include tableauserverclient/_version.py +include CHANGELOG.md +include contributing.md +include CONTRIBUTORS.md include LICENSE include LICENSE.versioneer include README.md -include CHANGELOG.md +include tableauserverclient/_version.py +include versioneer.py recursive-include docs *.md recursive-include samples *.py recursive-include samples *.txt -recursive-include smoke *.py recursive-include test *.csv recursive-include test *.dict recursive-include test *.hyper @@ -16,5 +17,6 @@ recursive-include test *.pdf recursive-include test *.png recursive-include test *.py recursive-include test *.xml +recursive-include test *.tde global-include *.pyi -global-include *.typed \ No newline at end of file +global-include *.typed diff --git a/contributing.md b/contributing.md index c5f0fa95e..90fbdc4f0 100644 --- a/contributing.md +++ b/contributing.md @@ -57,9 +57,8 @@ somewhere. ## Getting Started ```shell -pip install versioneer -python setup.py build -python setup.py test +python -m build +pytest ``` ### To use your locally built version diff --git a/pyproject.toml b/pyproject.toml index 1884a6b37..840c062e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,37 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer-518", "wheel"] +requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] build-backend = "setuptools.build_meta" +[project] +name="tableauserverclient" + +dynamic = ["version"] +description='A Python module for working with the Tableau Server REST API.' +authors = [{name="Tableau", email="github@tableau.com"}] +license = {file = "LICENSE"} +readme = "README.md" + +dependencies = [ + 'defusedxml>=0.7.1', + 'packaging~=21.3', + 'requests>=2.28', + 'urllib3~=1.26.8', +] +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10" +] +[project.urls] +repository = "https://github.com/tableau/server-client-python" + +[project.optional-dependencies] +test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "requests-mock>=1.0,<2.0"] + [tool.black] line-length = 120 target-version = ['py37', 'py38', 'py39', 'py310'] @@ -11,8 +41,10 @@ disable_error_code = [ 'misc', 'import' ] -files = [ - "tableauserverclient", - "test" -] +files = ["tableauserverclient", "test"] show_error_codes = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["test"] +addopts = "--junitxml=./test.junit.xml" diff --git a/samples/create_group.py b/samples/create_group.py index 3875ffea5..50d84a187 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -8,10 +8,13 @@ import argparse import logging +import os from datetime import time +from typing import List import tableauserverclient as TSC +from tableauserverclient import ServerResponseError def main(): @@ -35,7 +38,7 @@ def main(): ) # Options specific to this sample # This sample has no additional options, yet. If you add some, please add them here - + parser.add_argument("--file", help="csv file containing user info", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -45,9 +48,48 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): + # this code shows 3 different error codes that mean "resource is already in collection" + # 409009: group already exists on server + # 409107: user is already on site + # 409011: user is already in group + group = TSC.GroupItem("test") - group = server.groups.create(group) - print(group) + try: + group = server.groups.create(group) + except TSC.server.endpoint.exceptions.ServerResponseError as rError: + if rError.code == "409009": + print("Group already exists") + group = server.groups.filter(name=group.name)[0] + else: + raise rError + server.groups.populate_users(group) + for user in group.users: + print(user.name) + + if args.file: + filepath = os.path.abspath(args.file) + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] + added, failed = server.users.create_from_file(filepath) + for user, error in failed: + print(user, error.code) + if error.code == "409017": + user = server.users.filter(name=user.name)[0] + added.append(user) + print("Adding users to group:{}".format(added)) + for user in added: + print("Adding user {}".format(user)) + try: + server.groups.add_user(group, user.id) + except ServerResponseError as serverError: + if serverError.code == "409011": + print("user {} is already a member of group {}".format(user.name, group.name)) + else: + raise rError + + for user in group.users: + print(user.name) if __name__ == "__main__": diff --git a/samples/explore_site.py b/samples/explore_site.py new file mode 100644 index 000000000..8c4abd9d3 --- /dev/null +++ b/samples/explore_site.py @@ -0,0 +1,83 @@ +#### +# This script demonstrates how to use the Tableau Server Client +# to interact with sites. +#### + +import argparse +import logging +import os.path +import sys + +import tableauserverclient as TSC + + +def main(): + + parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument( + "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" + ) + parser.add_argument( + "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + parser.add_argument("--delete") + parser.add_argument("--create") + parser.add_argument("--url") + parser.add_argument("--new_site_name") + parser.add_argument("--user_quota") + parser.add_argument("--storage_quota") + parser.add_argument("--status") + + args = parser.parse_args() + + # 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.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + new_site = None + with server.auth.sign_in(tableau_auth): + current_site = server.sites.get_by_id(server.site_id) + + if args.delete: + print("You can only delete the site you are currently in") + print("Delete site `{}`?".format(current_site.name)) + # server.sites.delete(server.site_id) + + elif args.create: + new_site = TSC.SiteItem(args.create, args.url or args.create) + site_item = server.sites.create(new_site) + print(site_item) + # to do anything further with the site, you need to log into it + # if a PAT is required, that means going to the UI to create one + + else: + new_site = current_site + print(current_site, "current user quota:", current_site.user_quota) + print("Remember, you can only update the site you are currently in") + if args.url: + new_site.content_url = args.url + if args.user_quota: + new_site.user_quota = args.user_quota + try: + updated_site = server.sites.update(new_site) + print(updated_site, "new user quota:", updated_site.user_quota) + except TSC.ServerResponseError as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/samples/list.py b/samples/list.py index 814c1b9ca..b5cdb38a5 100644 --- a/samples/list.py +++ b/samples/list.py @@ -59,7 +59,10 @@ def main(): count = 0 for resource in TSC.Pager(endpoint.get, options): count = count + 1 - print(resource.id, resource.name) + # endpoint.populate_connections(resource) + print(resource.name[:18], " ") # , resource._connections()) + if count > 100: + break print("Total: {}".format(count)) diff --git a/samples/online_users.csv b/samples/online_users.csv new file mode 100644 index 000000000..bf4843679 --- /dev/null +++ b/samples/online_users.csv @@ -0,0 +1,2 @@ +ayoung@tableau.com, , , "Creator", None, Yes +ahsiao@tableau.com, , , "Explorer", None, No diff --git a/setup.cfg b/setup.cfg index dafb578b7..a551fdb6a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,27 +1,10 @@ -[wheel] -universal = 1 - -[pep8] -max_line_length = 120 - # See the docstring in versioneer.py for instructions. Note that you must # re-run 'versioneer.py setup' after changing this section, and commit the # resulting files. - +# versioneer does not support pyproject.toml [versioneer] VCS = git style = pep440-pre versionfile_source = tableauserverclient/_version.py versionfile_build = tableauserverclient/_version.py tag_prefix = v -#parentdir_prefix = - -[aliases] -smoke=pytest - -[tool:pytest] -testpaths = test smoke -addopts = --junitxml=./test.junit.xml - -[mypy] -ignore_missing_imports = True diff --git a/setup.py b/setup.py index 24d35250c..60d8fe6b8 100644 --- a/setup.py +++ b/setup.py @@ -1,49 +1,22 @@ -import sys import versioneer +from setuptools import setup -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - -from os import path -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -# Only install pytest and runner when test command is run -# This makes work easier for offline installs or low bandwidth machines -needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) -pytest_runner = ['pytest-runner'] if needs_pytest else [] -test_requirements = ['black', 'mock', 'pytest', 'requests-mock>=1.0,<2.0', 'mypy>=0.920'] - +""" +once versioneer 0.25 gets released, we can move this from setup.cfg to pyproject.toml +[tool.versioneer] +VCS = "git" +style = "pep440-pre" +versionfile_source = "tableauserverclient/_version.py" +versionfile_build = "tableauserverclient/_version.py" +tag_prefix = "v" +""" setup( - name='tableauserverclient', version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), - author='Tableau', - author_email='github@tableau.com', - url='https://github.com/tableau/server-client-python', - package_data={'tableauserverclient':['py.typed']}, + # not yet sure how to move this to pyproject.toml packages=['tableauserverclient', 'tableauserverclient.helpers', 'tableauserverclient.models', 'tableauserverclient.server', 'tableauserverclient.server.endpoint'], - license='MIT', - description='A Python module for working with the Tableau Server REST API.', - long_description=long_description, - long_description_content_type='text/markdown', - test_suite='test', - setup_requires=pytest_runner, - install_requires=[ - 'defusedxml>=0.7.1', - 'requests>=2.11,<3.0', - ], - python_requires='>3.7.0', - tests_require=test_requirements, - extras_require={ - 'test': test_requirements - }, - zip_safe=False ) diff --git a/smoke/__init__.py b/smoke/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 592551b4e..394184120 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,62 +1,52 @@ -from ._version import get_versions +from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ( + BackgroundJobItem, + ColumnItem, ConnectionCredentials, ConnectionItem, + DQWItem, + DailyInterval, DataAlertItem, + DatabaseItem, DatasourceItem, - DQWItem, + FlowItem, + FlowRunItem, GroupItem, + HourlyInterval, + IntervalItem, JobItem, - BackgroundJobItem, + MetricItem, + MonthlyInterval, PaginationItem, + Permission, + PermissionsRule, + PersonalAccessTokenAuth, ProjectItem, + RevisionItem, ScheduleItem, SiteItem, + SubscriptionItem, + TableItem, TableauAuth, - PersonalAccessTokenAuth, + Target, + TaskItem, + UnpopulatedPropertyError, UserItem, ViewItem, - WorkbookItem, - UnpopulatedPropertyError, - HourlyInterval, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - IntervalItem, - TaskItem, - SubscriptionItem, - Target, - PermissionsRule, - Permission, - DatabaseItem, - TableItem, - ColumnItem, - FlowItem, WebhookItem, - PersonalAccessTokenAuth, - FlowRunItem, - RevisionItem, - MetricItem, - TableauItem, - Resource, - plural_type, + WeeklyInterval, + WorkbookItem, ) -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .server import ( - RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, - Filter, - Sort, - Server, - ServerResponseError, + RequestOptions, MissingRequiredFieldError, NotSignedInError, + ServerResponseError, + Filter, Pager, + Server, + Sort, ) -from .helpers import * - -__version__ = get_versions()["version"] -__VERSION__ = __version__ -del get_versions diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 2b1df202c..0d968428d 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,12 +1,12 @@ import datetime -# This code below is from the python documentation for -# tzinfo: https://docs.python.org/2.3/lib/datetime-tzinfo.html ZERO = datetime.timedelta(0) HOUR = datetime.timedelta(hours=1) +# This class is a concrete implementation of the abstract base class tzinfo +# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): """UTC""" diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 17ca20bb9..ed7733076 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,44 +1,48 @@ +from typing import TYPE_CHECKING, List, Optional from defusedxml.ElementTree import fromstring from .connection_credentials import ConnectionCredentials +if TYPE_CHECKING: + from tableauserverclient.models.connection_credentials import ConnectionCredentials + class ConnectionItem(object): def __init__(self): - self._datasource_id = None - self._datasource_name = None - self._id = None - self._connection_type = None - self.embed_password = None - self.password = None - self.server_address = None - self.server_port = None - self.username = None - self.connection_credentials = None + self._datasource_id: Optional[str] = None + self._datasource_name: Optional[str] = None + self._id: Optional[str] = None + self._connection_type: Optional[str] = None + self.embed_password: bool = None + self.password: Optional[str] = None + self.server_address: Optional[str] = None + self.server_port: Optional[str] = None + self.username: Optional[str] = None + self.connection_credentials: Optional["ConnectionCredentials"] = None @property - def datasource_id(self): + def datasource_id(self) -> Optional[str]: return self._datasource_id @property - def datasource_name(self): + def datasource_name(self) -> Optional[str]: return self._datasource_name @property - def id(self): + def id(self) -> Optional[str]: return self._id @property - def connection_type(self): + def connection_type(self) -> Optional[str]: return self._connection_type def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns) -> List["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -58,7 +62,7 @@ def from_response(cls, resp, ns): return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns): + def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ @@ -69,7 +73,7 @@ def from_xml_element(cls, parsed_response, ns): """ - all_connection_items = list() + all_connection_items: List["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: @@ -82,11 +86,13 @@ def from_xml_element(cls, parsed_response, ns): if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials) + connection_item.connection_credentials = ConnectionCredentials.from_xml_element( + connection_credentials, ns + ) return all_connection_items # Used to convert string represented boolean to a boolean type -def string_to_bool(s): +def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6fcf18544..eb03b1b5d 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -27,6 +27,11 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + def __str__(self): + return "{}({!r})".format(self.__class__.__name__, self.__dict__) + + __repr__ = __str__ + @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -74,9 +79,6 @@ def users(self) -> "Pager": # Each call to `.users` should create a new pager, this just runs the callable return self._users() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9237d134e..acb14ce91 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,9 +9,6 @@ from typing import List, Optional -from typing import List, Optional, TYPE_CHECKING - - class ProjectItem(object): class ContentPermissions: LockedToProject: str = "LockedToProject" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d27acabf..3deda03e2 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,8 +1,8 @@ import warnings import xml.etree.ElementTree as ET +from distutils.version import Version from defusedxml.ElementTree import fromstring - from .property_decorators import ( property_is_enum, property_is_boolean, @@ -14,7 +14,10 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union +from typing import List, Optional, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from tableauserverclient.server import Server class SiteItem(object): @@ -23,6 +26,19 @@ class SiteItem(object): _tier_explorer_capacity: Optional[int] = None _tier_viewer_capacity: Optional[int] = None + def __str__(self): + return ( + "<" + + __name__ + + ": " + + (self.name or "unnamed") + + ", " + + (self.id or "unknown-id") + + ", " + + (self.state or "unknown-state") + + ">" + ) + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" @@ -261,6 +277,13 @@ def cataloging_enabled(self) -> bool: def cataloging_enabled(self, value: bool): self._cataloging_enabled = value + def is_default(self) -> bool: + return self.name.lower() == "default" + + @staticmethod + def use_new_flow_settings(parent_srv: "Server") -> bool: + return parent_srv is not None and parent_srv.check_at_least_version("3.10") + @property def flows_enabled(self) -> bool: return self._flows_enabled @@ -268,11 +291,10 @@ def flows_enabled(self) -> bool: @flows_enabled.setter @property_is_boolean def flows_enabled(self, value: bool) -> None: + # Flows Enabled' is not a supported site setting in API Version [3.17]. + # In Version 3.10+ use the more granular settings 'Editing Flows Enabled' and/or 'Scheduling Flows Enabled' self._flows_enabled = value - def is_default(self) -> bool: - return self.name.lower() == "default" - @property def editing_flows_enabled(self) -> bool: return self._editing_flows_enabled diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index f60e72951..032841dc7 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,7 +1,8 @@ -from datetime import datetime +import io +import logging import xml.etree.ElementTree as ET from datetime import datetime -from typing import Dict, List, Optional, TYPE_CHECKING +from enum import IntEnum from defusedxml.ElementTree import fromstring @@ -9,15 +10,11 @@ from .property_decorators import ( property_is_enum, property_not_empty, - property_not_nullable, ) from .reference_item import ResourceReference from ..datetime_helpers import parse_datetime -if TYPE_CHECKING: - from ..server.pager import Pager - -from typing import Dict, List, Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server.pager import Pager @@ -72,6 +69,10 @@ def __init__( return None + def __repr__(self) -> str: + str_site_role = self.site_role or "None" + return "".format(self.id, self.name, str_site_role) + @property def auth_setting(self) -> Optional[str]: return self._auth_setting @@ -106,12 +107,24 @@ def name(self) -> Optional[str]: def name(self, value: str): self._name = value + # valid: username, domain/username, username@domain, domain/username@email + @staticmethod + def validate_username_or_throw(username) -> None: + if username is None or username == "" or username.strip(" ") == "": + raise AttributeError("Username cannot be empty") + if username.find(" ") >= 0: + raise AttributeError("Username cannot contain spaces") + at_symbol = username.find("@") + if at_symbol >= 0: + username = username[:at_symbol] + "X" + username[at_symbol + 1 :] + if username.find("@") >= 0: + raise AttributeError("Username cannot repeat '@'") + @property def site_role(self) -> Optional[str]: return self._site_role @site_role.setter - @property_not_nullable @property_is_enum(Roles) def site_role(self, value): self._site_role = value @@ -137,9 +150,6 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() - def to_reference(self) -> ResourceReference: - return ResourceReference(id_=self.id, tag_name=self.tag_name) - def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -259,5 +269,149 @@ def _parse_element(user_xml, ns): domain_name, ) - def __repr__(self) -> str: - return "".format(self.id, self.name, self.site_role) + class CSVImport(object): + """ + This class includes hardcoded options and logic for the CSV file format defined for user import + https://help.tableau.com/current/server/en-us/users_import.htm + """ + + # username, password, display_name, license, admin_level, publishing, email, auth type + class ColumnType(IntEnum): + USERNAME = 0 + PASS = 1 + DISPLAY_NAME = 2 + LICENSE = 3 # aka site role + ADMIN = 4 + PUBLISHER = 5 + EMAIL = 6 + AUTH = 7 + + MAX = 7 + + # Read a csv line and create a user item populated by the given attributes + @staticmethod + def create_user_from_line(line: str): + if line is None or line is False or line == "\n" or line == "": + return None + line = line.strip().lower() + values: List[str] = list(map(str.strip, line.split(","))) + user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) + if len(values) > 1: + if len(values) > UserItem.CSVImport.ColumnType.MAX: + raise ValueError("Too many attributes for user import") + while len(values) <= UserItem.CSVImport.ColumnType.MAX: + values.append("") + site_role = UserItem.CSVImport._evaluate_site_role( + values[UserItem.CSVImport.ColumnType.LICENSE], + values[UserItem.CSVImport.ColumnType.ADMIN], + values[UserItem.CSVImport.ColumnType.PUBLISHER], + ) + + user._set_values( + None, + values[UserItem.CSVImport.ColumnType.USERNAME], + site_role, + None, + None, + values[UserItem.CSVImport.ColumnType.DISPLAY_NAME], + values[UserItem.CSVImport.ColumnType.EMAIL], + values[UserItem.CSVImport.ColumnType.AUTH], + None, + ) + return user + + # Read through an entire CSV file meant for user import + # Return the number of valid lines and a list of all the invalid lines + @staticmethod + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + num_valid_lines = 0 + invalid_lines = [] + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + try: + # do not print passwords + logger.info("Reading user {}".format(line[:4])) + UserItem.CSVImport._validate_import_line_or_throw(line, logger) + num_valid_lines += 1 + except Exception as exc: + logger.info("Error parsing {}: {}".format(line[:4], exc)) + invalid_lines.append(line) + line = csv_file.readline() + return num_valid_lines, invalid_lines + + # Some fields in the import file are restricted to specific values + # Iterate through each field and validate the given value against hardcoded constraints + @staticmethod + def _validate_import_line_or_throw(incoming, logger) -> None: + _valid_attributes: List[List[str]] = [ + [], + [], + [], + ["creator", "explorer", "viewer", "unlicensed"], # license + ["system", "site", "none", "no"], # admin + ["yes", "true", "1", "no", "false", "0"], # publisher + [], + [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth + ] + + line = list(map(str.strip, incoming.split(","))) + if len(line) > UserItem.CSVImport.ColumnType.MAX: + raise AttributeError("Too many attributes in line") + username = line[UserItem.CSVImport.ColumnType.USERNAME.value] + logger.debug("> details - {}".format(username)) + UserItem.validate_username_or_throw(username) + for i in range(1, len(line)): + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + UserItem.CSVImport._validate_attribute_value( + line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) + ) + + # Given a restricted set of possible values, confirm the item is in that set + @staticmethod + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + if item is None or item == "": + # value can be empty for any column except user, which is checked elsewhere + return + if item in possible_values or possible_values == []: + return + raise AttributeError("Invalid value {} for {}".format(item, column_type)) + + # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles + # This logic is hardcoded to match the existing rules for import csv files + @staticmethod + def _evaluate_site_role(license_level, admin_level, publisher): + if not license_level or not admin_level or not publisher: + return "Unlicensed" + # ignore case everywhere + license_level = license_level.lower() + admin_level = admin_level.lower() + publisher = publisher.lower() + # don't need to check publisher for system/site admin + if admin_level == "system": + site_role = "SiteAdministrator" + elif admin_level == "site": + if license_level == "creator": + site_role = "SiteAdministratorCreator" + elif license_level == "explorer": + site_role = "SiteAdministratorExplorer" + else: + site_role = "SiteAdministratorExplorer" + else: # if it wasn't 'system' or 'site' then we can treat it as 'none' + if publisher == "yes": + if license_level == "creator": + site_role = "Creator" + elif license_level == "explorer": + site_role = "ExplorerCanPublish" + else: + site_role = "Unlicensed" # is this the expected outcome? + else: # publisher == 'no': + if license_level == "explorer" or license_level == "creator": + site_role = "Explorer" + elif license_level == "viewer": + site_role = "Viewer" + else: # if license_level == 'unlicensed' + site_role = "Unlicensed" + if site_role is None: + site_role = "Unlicensed" + return site_role diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index cb680d914..25abb3c9a 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -9,7 +9,7 @@ from .filter import Filter from .sort import Sort -from .. import ( +from ..models import ( BackgroundJobItem, ColumnItem, ConnectionItem, diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 11e89975a..6baf399ed 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -30,7 +30,7 @@ def sign_in(self, auth_req): signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options) self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) @@ -66,7 +66,7 @@ def switch_site(self, site_item): else: raise e self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response) + self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 0acc978d2..378c84746 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -11,6 +11,7 @@ NonXMLResponseError, EndpointUnavailableError, ) +from .. import endpoint from ..query import QuerySet from ... import helpers @@ -26,18 +27,29 @@ from requests import Response +_version_header: Optional[str] = None + + class Endpoint(object): def __init__(self, parent_srv: "Server"): + global _version_header self.parent_srv = parent_srv @staticmethod def _make_common_headers(auth_token, content_type): + global _version_header + + if not _version_header: + from ..server import __TSC_VERSION__ + + _version_header = __TSC_VERSION__ + headers = {} if auth_token is not None: headers["x-tableau-auth"] = auth_token if content_type is not None: headers["content-type"] = content_type - + headers["User-Agent"] = "Tableau Server Client/{}".format(_version_header) return headers def _make_request( @@ -63,7 +75,7 @@ def _make_request( logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) server_response = method(url, **parameters) - self._check_status(server_response) + self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) @@ -73,13 +85,13 @@ def _make_request( return server_response - def _check_status(self, server_response): + def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: - raise InternalServerError(server_response) + raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: # todo: is an error reliably of content-type application/xml? try: - raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that # doesn't return an xml error object (like metadata endpoints or 503 pages) @@ -112,7 +124,7 @@ def get_request(self, url, request_object=None, parameters=None): if request_object is not None: try: # Query param delimiters don't need to be encoded for versions before 3.7 (2020.1) - self.parent_srv.assert_at_least_version("3.7") + self.parent_srv.assert_at_least_version("3.7", "Query param encoding") parameters = parameters or {} parameters["params"] = request_object.get_query_params() except EndpointUnavailableError: @@ -126,7 +138,7 @@ def get_request(self, url, request_object=None, parameters=None): ) def delete_request(self, url): - # We don't return anything for a delete + # We don't return anything for a delete request self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token) def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None): @@ -182,7 +194,7 @@ def api(version): def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - self.parent_srv.assert_at_least_version(version) + self.parent_srv.assert_at_least_version(version, "endpoint") return func(self, *args, **kwargs) return wrapper diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 34de00dd0..3ce0d5e92 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,62 +1,72 @@ from defusedxml.ElementTree import fromstring -class ServerResponseError(Exception): - def __init__(self, code, summary, detail): +class TableauError(Exception): + pass + + +class ServerResponseError(TableauError): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail + self.url = url super(ServerResponseError, self).__init__(str(self)) def __str__(self): return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) - error_response = cls( - parsed_response.find("t:error", namespaces=ns).get("code", ""), - parsed_response.find(".//t:summary", namespaces=ns).text, - parsed_response.find(".//t:detail", namespaces=ns).text, - ) + try: + error_response = cls( + parsed_response.find("t:error", namespaces=ns).get("code", ""), + parsed_response.find(".//t:summary", namespaces=ns).text, + parsed_response.find(".//t:detail", namespaces=ns).text, + url, + ) + except Exception as e: + raise NonXMLResponseError(resp) return error_response -class InternalServerError(Exception): - def __init__(self, server_response): +class InternalServerError(TableauError): + def __init__(self, server_response, request_url: str = None): self.code = server_response.status_code self.content = server_response.content + self.url = request_url or "server" def __str__(self): - return "\n\nError status code: {0}\n{1}".format(self.code, self.content) + return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) -class MissingRequiredFieldError(Exception): +class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(Exception): +class ServerInfoEndpointNotFoundError(TableauError): pass -class EndpointUnavailableError(Exception): +class EndpointUnavailableError(TableauError): pass -class ItemTypeNotAllowed(Exception): +class ItemTypeNotAllowed(TableauError): pass -class NonXMLResponseError(Exception): +class NonXMLResponseError(TableauError): pass -class InvalidGraphQLQuery(Exception): +class InvalidGraphQLQuery(TableauError): pass -class GraphQLError(Exception): +class GraphQLError(TableauError): def __init__(self, error_payload): self.error = error_payload @@ -66,7 +76,7 @@ def __str__(self): return pformat(self.error) -class JobFailedException(Exception): +class JobFailedException(TableauError): def __init__(self, job): self.notes = job.notes self.job = job @@ -79,7 +89,7 @@ class JobCancelledException(JobFailedException): pass -class FlowRunFailedException(Exception): +class FlowRunFailedException(TableauError): def __init__(self, flow_run): self.background_job_id = flow_run.background_job_id self.flow_run = flow_run diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 99870ac34..6b709efad 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -29,7 +29,7 @@ def get( if isinstance(job_id, RequestOptionsBase): req_options = job_id - self.parent_srv.assert_at_least_version("3.1") + self.parent_srv.assert_at_least_version("3.1", "Jobs.get_by_id(job_id)") server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) jobs = BackgroundJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 5c9461d1c..2036d8d5e 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -26,6 +26,7 @@ def get(self): raise ServerInfoEndpointNotFoundError if e.code == "404001": raise EndpointUnavailableError + raise e server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) return server_info diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index bdf281fb9..67d7db209 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -22,6 +22,7 @@ def baseurl(self) -> str: @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") + logger.info("Requires Server Admin permissions") url = self.baseurl server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,6 +35,10 @@ def get_by_id(self, site_id: str) -> SiteItem: if not site_id: error = "Site ID undefined." raise ValueError(error) + if not site_id == self.parent_srv.site_id: + error = "You can only retrieve the site for which you are currently authenticated." + raise ValueError(error) + logger.info("Querying single site (ID: {0})".format(site_id)) url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) @@ -45,8 +50,10 @@ def get_by_name(self, site_name: str) -> SiteItem: if not site_name: error = "Site Name undefined." raise ValueError(error) + print("Note: You can only work with the site for which you are currently authenticated") logger.info("Querying single site (Name: {0})".format(site_name)) url = "{0}/{1}?key=name".format(self.baseurl, site_name) + print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,7 +63,12 @@ def get_by_content_url(self, content_url: str) -> SiteItem: if content_url is None: error = "Content URL undefined." raise ValueError(error) + if not self.parent_srv.baseurl.index(content_url) > 0: + error = "You can only work with the site you are currently authenticated for" + raise ValueError(error) + logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.debug("Querying other sites requires Server Admin permissions") url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -67,13 +79,18 @@ def update(self, site_item: SiteItem) -> SiteItem: if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) + print(self.parent_srv.site_id, site_item.id) + if not site_item.id == self.parent_srv.site_id: + error = "You can only update the site you are currently authenticated for" + raise ValueError(error) + if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_item.id) - update_req = RequestFactory.Site.update_req(site_item) + update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) @@ -86,12 +103,11 @@ def delete(self, site_id: str) -> None: error = "Site ID undefined." raise ValueError(error) url = "{0}/{1}".format(self.baseurl, site_id) + if not site_id == self.parent_srv.site_id: + error = "You can only delete the site you are currently authenticated for" + raise ValueError(error) self.delete_request(url) - # If we deleted the site we are logged into - # then we are automatically logged out - if site_id == self.parent_srv.site_id: - logger.info("Deleting current site and clearing auth tokens") - self.parent_srv._clear_auth() + self.parent_srv._clear_auth() logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # Create new site @@ -103,7 +119,7 @@ def create(self, site_item: SiteItem) -> SiteItem: raise ValueError(error) url = self.baseurl - create_req = RequestFactory.Site.create_req(site_item) + create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] logger.info("Created new site (ID: {0})".format(new_site.id)) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index f147c79ae..a70480b91 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -25,7 +25,7 @@ def __normalize_task_type(self, task_type): @api(version="2.6") def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): if task_type == TaskItem.Type.DataAcceleration: - self.parent_srv.assert_at_least_version("3.8") + self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all {} tasks for the site".format(task_type)) @@ -69,7 +69,7 @@ def run(self, task_item): @api(version="3.6") def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): if task_type == TaskItem.Type.DataAcceleration: - self.parent_srv.assert_at_least_version("3.8") + self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") if not task_id: error = "No Task ID provided" diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 738364cd7..28406ab71 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,19 +1,16 @@ import copy import logging -from typing import List, Optional, Tuple +import os +from typing import List, Optional, Tuple, Union from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError -from .. import ( - RequestFactory, - RequestOptions, - UserItem, - WorkbookItem, - PaginationItem, - GroupItem, -) +from .exceptions import MissingRequiredFieldError, ServerResponseError +from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager +# duplicate defined in workbooks_endpoint +FilePath = Union[str, os.PathLike] + logger = logging.getLogger("tableau.endpoint.users") @@ -78,12 +75,51 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: url = self.baseurl + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) + logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user + # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar + @api(version="2.0") + def add_all(self, users: List[UserItem]): + created = [] + failed = [] + for user in users: + try: + result = self.add(user) + created.append(result) + except Exception as e: + failed.append(user) + return created, failed + + # helping the user by parsing a file they could have used to add users through the UI + # line format: Username [required], password, display name, license, admin, publish + @api(version="2.0") + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + created = [] + failed = [] + if not filepath.find("csv"): + raise ValueError("Only csv files are accepted") + + with open(filepath) as csv_file: + csv_file.seek(0) # set to start of file in case it has been read earlier + line: str = csv_file.readline() + while line and line != "": + user: UserItem = UserItem.CSVImport.create_user_from_line(line) + try: + print(user) + result = self.add(user) + created.append(result) + except ServerResponseError as serverError: + print("failed") + failed.append((user, serverError)) + line = csv_file.readline() + return created, failed + # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 67e66a81f..06cc08349 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -12,7 +12,13 @@ from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..request_options import RequestOptions, CSVRequestOptions, PDFRequestOptions, ImageRequestOptions + from ..request_options import ( + RequestOptions, + CSVRequestOptions, + PDFRequestOptions, + ImageRequestOptions, + ExcelRequestOptions, + ) class Views(QuerysetEndpoint): @@ -126,7 +132,7 @@ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOp yield from server_response.iter_content(1024) @api(version="3.8") - def populate_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -137,7 +143,7 @@ def excel_fetcher(): view_item._set_excel(excel_fetcher) logger.info("Populated excel for view (ID: {0})".format(view_item.id)) - def _get_view_excel(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: + def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 729447822..c5613b2d6 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,13 +1,21 @@ +from typing import Tuple from .filter import Filter from .request_options import RequestOptions from .sort import Sort import math -def to_camel_case(word): +def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) +""" +This interface allows more fluent queries against Tableau Server +e.g server.users.get(name="user@domain.com") +see pagination_sample +""" + + class QuerySet: def __init__(self, model): self.model = model @@ -85,18 +93,21 @@ def _fetch_all(self): if self._result_cache is None: self._result_cache, self._pagination_item = self.model.get(self.request_options) + def __len__(self) -> int: + return self.total_available + @property - def total_available(self): + def total_available(self) -> int: self._fetch_all() return self._pagination_item.total_available @property - def page_number(self): + def page_number(self) -> int: self._fetch_all() return self._pagination_item.page_number @property - def page_size(self): + def page_size(self) -> int: self._fetch_all() return self._pagination_item.page_size @@ -121,7 +132,7 @@ def paginate(self, **kwargs): self.request_options.pagesize = kwargs["page_size"] return self - def _parse_shorthand_filter(self, key): + def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -135,7 +146,7 @@ def _parse_shorthand_filter(self, key): raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self, key): + def _parse_shorthand_sort(self, key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc00ca085..aad8ca074 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,6 +1,6 @@ from os import name import xml.etree.ElementTree as ET -from typing import Any, Dict, List, Optional, Tuple, Iterable +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -16,8 +16,6 @@ from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem from ..models import WebhookItem -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Iterable - if TYPE_CHECKING: from ..models import SubscriptionItem from ..models import DataAlertItem @@ -25,6 +23,7 @@ from ..models import ConnectionItem from ..models import SiteItem from ..models import ProjectItem + from tableauserverclient.server import Server def _add_multipart(parts: Dict) -> Tuple[Any, str]: @@ -39,7 +38,7 @@ def _add_multipart(parts: Dict) -> Tuple[Any, str]: def _tsrequest_wrapped(func): - def wrapper(self, *args, **kwargs): + def wrapper(self, *args, **kwargs) -> bytes: xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -556,7 +555,7 @@ def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = Tas """ if not isinstance(id_, str): - raise ValueError(f"id_ should be a string, reeceived: {type(id_)}") + raise ValueError(f"id_ should be a string, received: {type(id_)}") xml_request = ET.Element("tsRequest") task_element = ET.SubElement(xml_request, "task") task = ET.SubElement(task_element, task_type) @@ -576,7 +575,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo class SiteRequest(object): - def update_req(self, site_item: "SiteItem"): + def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -601,14 +600,15 @@ def update_req(self, site_item: "SiteItem"): site_element.attrib["revisionHistoryEnabled"] = str(site_item.revision_history_enabled).lower() if site_item.data_acceleration_mode is not None: site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() - if site_item.flows_enabled is not None: - site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() if site_item.cataloging_enabled is not None: site_element.attrib["catalogingEnabled"] = str(site_item.cataloging_enabled).lower() - if site_item.editing_flows_enabled is not None: - site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() - if site_item.scheduling_flows_enabled is not None: - site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() + + flows_edit = str(site_item.editing_flows_enabled).lower() + flows_schedule = str(site_item.scheduling_flows_enabled).lower() + flows_all = str(site_item.flows_enabled).lower() + + self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item) + if site_item.allow_subscription_attachments is not None: site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: @@ -682,7 +682,8 @@ def update_req(self, site_item: "SiteItem"): return ET.tostring(xml_request) - def create_req(self, site_item: "SiteItem"): + # server: the site request model changes based on api version + def create_req(self, site_item: "SiteItem", parent_srv: "Server" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -701,12 +702,13 @@ def create_req(self, site_item: "SiteItem"): site_element.attrib["revisionLimit"] = str(site_item.revision_limit) if site_item.data_acceleration_mode is not None: site_element.attrib["dataAccelerationMode"] = str(site_item.data_acceleration_mode).lower() - if site_item.flows_enabled is not None: - site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() - if site_item.editing_flows_enabled is not None: - site_element.attrib["editingFlowsEnabled"] = str(site_item.editing_flows_enabled).lower() - if site_item.scheduling_flows_enabled is not None: - site_element.attrib["schedulingFlowsEnabled"] = str(site_item.scheduling_flows_enabled).lower() + + flows_edit = str(site_item.editing_flows_enabled).lower() + flows_schedule = str(site_item.scheduling_flows_enabled).lower() + flows_all = str(site_item.flows_enabled).lower() + + self.set_versioned_flow_attributes(flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item) + if site_item.allow_subscription_attachments is not None: site_element.attrib["allowSubscriptionAttachments"] = str(site_item.allow_subscription_attachments).lower() if site_item.guest_access_enabled is not None: @@ -784,6 +786,32 @@ def create_req(self, site_item: "SiteItem"): return ET.tostring(xml_request) + def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, parent_srv, site_element, site_item): + if (not parent_srv) or SiteItem.use_new_flow_settings(parent_srv): + if site_item.flows_enabled is not None: + flows_edit = flows_edit or flows_all + flows_schedule = flows_schedule or flows_all + import warnings + + warnings.warn( + "FlowsEnabled has been removed and become two options:" + " SchedulingFlowsEnabled and EditingFlowsEnabled" + ) + if site_item.editing_flows_enabled is not None: + site_element.attrib["editingFlowsEnabled"] = flows_edit + if site_item.scheduling_flows_enabled is not None: + site_element.attrib["schedulingFlowsEnabled"] = flows_schedule + + else: + if site_item.flows_enabled is not None: + site_element.attrib["flowsEnabled"] = str(site_item.flows_enabled).lower() + if site_item.editing_flows_enabled is not None or site_item.scheduling_flows_enabled is not None: + flows_all = flows_all or flows_edit or flows_schedule + site_element.attrib["flowsEnabled"] = flows_all + import warnings + + warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") + class TableRequest(object): def update_req(self, table_item): @@ -971,15 +999,15 @@ def embedded_extract_req(self, xml_request, include_all=True, datasources=None): class Connection(object): @_tsrequest_wrapped - def update_req(self, xml_request, connection_item): + def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") - if connection_item.server_address: + if connection_item.server_address is not None: connection_element.attrib["serverAddress"] = connection_item.server_address.lower() - if connection_item.server_port: + if connection_item.server_port is not None: connection_element.attrib["serverPort"] = str(connection_item.server_port) - if connection_item.username: + if connection_item.username is not None: connection_element.attrib["userName"] = connection_item.username - if connection_item.password: + if connection_item.password is not None: connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 4462ba786..f4ed8fd3c 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,7 @@ from ..models.property_decorators import property_is_int +import logging + +logger = logging.getLogger("tableau.request_options") class RequestOptionsBase(object): @@ -8,6 +11,8 @@ def apply_query_params(self, url): params = self.get_query_params() params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) + if "?" in url: url, existing_params = url.split("?") params_list.append(existing_params) @@ -142,6 +147,28 @@ def get_query_params(self): return params +class ExcelRequestOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1) -> None: + super().__init__() + self.max_age = maxage + + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value: int) -> None: + self._max_age = value + + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + + return params + + class ImageRequestOptions(_FilterOptionsBase): # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e35514474..c82f4a6e2 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,8 @@ -import urllib3 import requests +import urllib3 + from defusedxml.ElementTree import fromstring from packaging.version import Version - from .endpoint import ( Sites, Views, @@ -30,15 +30,17 @@ Metrics, ) from .endpoint.exceptions import ( - EndpointUnavailableError, ServerInfoEndpointNotFoundError, + EndpointUnavailableError, ) from .exceptions import NotSignedInError from ..namespace import Namespace -import requests -from packaging.version import Version +from .._version import get_versions + +__TSC_VERSION__ = get_versions()["version"] +del get_versions _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", @@ -47,6 +49,9 @@ "9.1": "2.0", "9.0": "2.0", } +minimum_supported_server_version = "2.3" +default_server_version = "2.3" +client_version_header = "X-TableauServerClient-Version" class Server(object): @@ -55,7 +60,7 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=True, http_options=None): + def __init__(self, server_address, use_server_version=False, http_options=None): self._server_address = server_address self._auth_token = None self._site_id = None @@ -63,7 +68,7 @@ def __init__(self, server_address, use_server_version=True, http_options=None): self._session = requests.Session() self._http_options = dict() - self.version = "2.3" + self.version = default_server_version self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -90,8 +95,10 @@ def __init__(self, server_address, use_server_version=True, http_options=None): self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) + # must set this before calling use_server_version, because that's a server call if http_options: self.add_http_options(http_options) + self.add_http_version_header() if use_server_version: self.use_server_version() @@ -101,8 +108,13 @@ def add_http_options(self, options_dict): if options_dict.get("verify") == False: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def add_http_version_header(self): + if not self._http_options[client_version_header]: + self._http_options.update({client_version_header: __TSC_VERSION__}) + def clear_http_options(self): self._http_options = dict() + self.add_http_version_header() def _clear_auth(self): self._site_id = None @@ -144,13 +156,14 @@ def use_highest_version(self): warnings.warn("use use_server_version instead", DeprecationWarning) - def assert_at_least_version(self, version): + def check_at_least_version(self, target: str): server_version = Version(self.version or "0.0") - minimum_supported = Version(version) - if server_version < minimum_supported: - error = "This endpoint is not available in API version {}. Requires {}".format( - server_version, minimum_supported - ) + target_version = Version(target) + return server_version >= target_version + + def assert_at_least_version(self, comparison: str, reason: str): + if not self.check_at_least_version(comparison): + error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) raise EndpointUnavailableError(error) @property diff --git a/test/assets/Data/user_details.csv b/test/assets/Data/user_details.csv new file mode 100644 index 000000000..15b975942 --- /dev/null +++ b/test/assets/Data/user_details.csv @@ -0,0 +1 @@ +username, pword, , yes, email diff --git a/test/assets/Data/usernames.csv b/test/assets/Data/usernames.csv new file mode 100644 index 000000000..0350c0dd6 --- /dev/null +++ b/test/assets/Data/usernames.csv @@ -0,0 +1,7 @@ +valid, +valid@email.com, +domain/valid, +domain/valid@tmail.com, +va!@#$%^&*()lid, +in@v@lid, +in valid, diff --git a/test/assets/site_get_by_id.xml b/test/assets/site_get_by_id.xml index a47703fb6..a8a1e9a5c 100644 --- a/test/assets/site_get_by_id.xml +++ b/test/assets/site_get_by_id.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/assets/site_get_by_name.xml b/test/assets/site_get_by_name.xml index 852f9594f..b7ae2b595 100644 --- a/test/assets/site_get_by_name.xml +++ b/test/assets/site_get_by_name.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/assets/site_update.xml b/test/assets/site_update.xml index dbb166de1..1661a426b 100644 --- a/test/assets/site_update.xml +++ b/test/assets/site_update.xml @@ -1,4 +1,4 @@ - - \ No newline at end of file + + diff --git a/test/test_group.py b/test/test_group.py index d948090ca..306d42170 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,9 +1,7 @@ # encoding=utf-8 -import os import unittest - +import os import requests_mock - import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime @@ -129,8 +127,7 @@ def test_add_user_before_populating(self) -> None: with requests_mock.mock() as m: m.get(self.baseurl, text=get_xml_response) m.post( - "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" - "-63f5805dbe3c/users", + self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users", text=add_user_response, ) all_groups, pagination_item = self.server.groups.get() @@ -163,8 +160,7 @@ def test_remove_user_before_populating(self) -> None: with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) m.delete( - "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/groups/ef8b19c0-43b6-11e6-af50" - "-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", text="ok", ) all_groups, pagination_item = self.server.groups.get() diff --git a/test/test_project.py b/test/test_project.py index 1d210eeb1..48e6005af 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient import GroupItem from ._utils import read_xml_asset, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -120,7 +121,7 @@ def test_update_datasource_default_permission(self) -> None: capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - rules = [TSC.PermissionsRule(grantee=group.to_reference(), capabilities=capabilities)] + rules = [TSC.PermissionsRule(grantee=GroupItem.as_reference(group._id), capabilities=capabilities)] new_rules = self.server.projects.update_datasource_default_permissions(project, rules) @@ -237,7 +238,7 @@ def test_delete_permission(self) -> None: if permission.grantee.id == single_group._id: capabilities = permission.capabilities - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) @@ -283,7 +284,7 @@ def test_delete_workbook_default_permission(self) -> None: TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, } - rules = TSC.PermissionsRule(grantee=single_group.to_reference(), capabilities=capabilities) + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) diff --git a/test/test_requests.py b/test/test_requests.py index 82859dd26..5c0d090ba 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -41,6 +41,7 @@ def test_make_post_request(self): ) self.assertEqual(resp.request.headers["x-tableau-auth"], "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM") self.assertEqual(resp.request.headers["content-type"], "multipart/mixed") + self.assertTrue(re.search("Tableau Server Client", resp.request.headers["user-agent"])) self.assertEqual(resp.request.body, b"1337") # Test that 500 server errors are handled properly diff --git a/test/test_site.py b/test/test_site.py index 23eb99ddd..b8469e56c 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -24,6 +24,9 @@ def setUp(self) -> None: self.server._site_id = "0626857c-1def-4503-a7d8-7907c3ff9d9f" self.baseurl = self.server.sites.baseurl + # sites APIs can only be called on the site being logged in to + self.logged_in_site = self.server.site_id + def test_get(self) -> None: with open(GET_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -71,10 +74,10 @@ def test_get_by_id(self) -> None: with open(GET_BY_ID_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl + "/dad65087-b08b-4603-af4e-2887b8aafc67", text=response_xml) - single_site = self.server.sites.get_by_id("dad65087-b08b-4603-af4e-2887b8aafc67") + m.get(self.baseurl + "/" + self.logged_in_site, text=response_xml) + single_site = self.server.sites.get_by_id(self.logged_in_site) - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("Active", single_site.state) self.assertEqual("Default", single_site.name) self.assertEqual("ContentOnly", single_site.admin_mode) @@ -95,7 +98,7 @@ def test_get_by_name(self) -> None: m.get(self.baseurl + "/testsite?key=name", text=response_xml) single_site = self.server.sites.get_by_name("testsite") - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("Active", single_site.state) self.assertEqual("testsite", single_site.name) self.assertEqual("ContentOnly", single_site.admin_mode) @@ -110,7 +113,7 @@ def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.put(self.baseurl + "/6b7179ba-b82b-4f0f-91ed-812074ac5da6", text=response_xml) + m.put(self.baseurl + "/" + self.logged_in_site, text=response_xml) single_site = TSC.SiteItem( name="Tableau", content_url="tableau", @@ -143,10 +146,11 @@ def test_update(self) -> None: tier_explorer_capacity=5, tier_viewer_capacity=5, ) - single_site._id = "6b7179ba-b82b-4f0f-91ed-812074ac5da6" + single_site._id = self.logged_in_site + self.server.sites.parent_srv = self.server single_site = self.server.sites.update(single_site) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", single_site.id) + self.assertEqual(self.logged_in_site, single_site.id) self.assertEqual("tableau", single_site.content_url) self.assertEqual("Suspended", single_site.state) self.assertEqual("Tableau", single_site.name) diff --git a/test/test_user.py b/test/test_user.py index b8fe32388..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -17,6 +20,9 @@ GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") +USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv") +USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") + class UserTests(unittest.TestCase): def setUp(self) -> None: @@ -212,3 +218,21 @@ def test_populate_groups(self) -> None: self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) self.assertEqual("TableauExample", group_list[2].name) self.assertEqual("local", group_list[2].domain_name) + + def test_get_usernames_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user_list, failures = self.server.users.create_from_file(USERNAMES) + assert user_list[0].name == "Cassie", user_list + assert failures == [], failures + + def test_get_users_from_file(self): + with open(ADD_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + users, failures = self.server.users.create_from_file(USERS) + assert users[0].name == "Cassie", users + assert failures == [] diff --git a/test/test_user_model.py b/test/test_user_model.py index ba70b1c7c..32d808f52 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,4 +1,10 @@ +import logging import unittest +from unittest.mock import * +from typing import List +import io + +import pytest import tableauserverclient as TSC @@ -23,3 +29,111 @@ def test_invalid_site_role(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): user.site_role = "Hello" + + +class UserDataTest(unittest.TestCase): + + logger = logging.getLogger("UserDataTest") + + role_inputs = [ + ["creator", "system", "yes", "SiteAdministrator"], + ["None", "system", "no", "SiteAdministrator"], + ["explorer", "SysTEm", "no", "SiteAdministrator"], + ["creator", "site", "yes", "SiteAdministratorCreator"], + ["explorer", "site", "yes", "SiteAdministratorExplorer"], + ["creator", "SITE", "no", "SiteAdministratorCreator"], + ["creator", "none", "yes", "Creator"], + ["explorer", "none", "yes", "ExplorerCanPublish"], + ["viewer", "None", "no", "Viewer"], + ["explorer", "no", "yes", "ExplorerCanPublish"], + ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"], + ["explorer", "no", "no", "Explorer"], + ["unlicensed", "none", "no", "Unlicensed"], + ["Chef", "none", "yes", "Unlicensed"], + ["yes", "yes", "yes", "Unlicensed"], + ] + + valid_import_content = [ + "username, pword, fname, creator, site, yes, email", + "username, pword, fname, explorer, none, no, email", + "", + "u", + "p", + ] + + valid_username_content = ["jfitzgerald@tableau.com"] + + usernames = [ + "valid", + "valid@email.com", + "domain/valid", + "domain/valid@tmail.com", + "va!@#$%^&*()lid", + "in@v@lid", + "in valid", + "", + ] + + def test_validate_usernames(self): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3]) + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4]) + with self.assertRaises(AttributeError): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5]) + with self.assertRaises(AttributeError): + TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6]) + + def test_evaluate_role(self): + for line in UserDataTest.role_inputs: + actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2]) + assert actual == line[3], line + [actual] + + def test_get_user_detail_empty_line(self): + test_line = "" + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user is None + + def test_get_user_detail_standard(self): + test_line = "username, pword, fname, license, admin, pub, email" + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user.name == "username", test_user.name + assert test_user.fullname == "fname", test_user.fullname + assert test_user.site_role == "Unlicensed", test_user.site_role + assert test_user.email == "email", test_user.email + + def test_get_user_details_only_username(self): + test_line = "username" + test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) + + def test_populate_user_details_only_some(self): + values = "username, , , creator, admin" + user = TSC.UserItem.CSVImport.create_user_from_line(values) + assert user.name == "username" + + def test_validate_user_detail_standard(self): + test_line = "username, pword, fname, creator, site, 1, email" + TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger) + TSC.UserItem.CSVImport.create_user_from_line(test_line) + + # for file handling + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + # the empty string represents EOF + # the tests run through the file twice, first to validate then to fetch + mock = MagicMock(io.TextIOWrapper) + content.append("") # EOF + mock.readline.side_effect = content + mock.name = "file-mock" + return mock + + def test_validate_import_file(self): + test_data = self._mock_file_content(UserDataTest.valid_import_content) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == [], "Expected no failures, got {}".format(invalid) + + def test_validate_usernames_file(self): + test_data = self._mock_file_content(UserDataTest.usernames) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) diff --git a/versioneer.py b/versioneer.py index 59211ed6f..86c240e13 100755 --- a/versioneer.py +++ b/versioneer.py @@ -277,6 +277,7 @@ """ from __future__ import print_function + try: import configparser except ImportError: @@ -308,11 +309,13 @@ def get_root(): setup_py = os.path.join(root, "setup.py") versioneer_py = os.path.join(root, "versioneer.py") if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ("Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND').") + err = ( + "Versioneer was unable to run the project root directory. " + "Versioneer requires setup.py to be executed from " + "its immediate directory (like 'python setup.py COMMAND'), " + "or in a way that lets it use sys.argv[0] to find the root " + "(like 'python path/to/setup.py COMMAND')." + ) raise VersioneerBadRootError(err) try: # Certain runtime workflows (setup.py install/develop in a setuptools @@ -325,8 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" - % (os.path.dirname(me), versioneer_py)) + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -348,6 +350,7 @@ def get(parser, name): if parser.has_option("versioneer", name): return parser.get("versioneer", name) return None + cfg = VersioneerConfig() cfg.VCS = VCS cfg.style = get(parser, "style") or "" @@ -372,17 +375,18 @@ class NotThisMethod(Exception): def register_vcs_handler(vcs, method): # decorator """Decorator to mark a method as the handler for a particular VCS.""" + def decorate(f): """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f + return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): """Call the given command(s).""" assert isinstance(commands, list) p = None @@ -390,10 +394,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, try: dispcmd = str([c] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + p = subprocess.Popen( + [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) + ) break except EnvironmentError: e = sys.exc_info()[1] @@ -418,7 +421,9 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, return stdout, p.returncode -LONG_VERSION_PY['git'] = ''' +LONG_VERSION_PY[ + "git" +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -993,7 +998,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1002,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1010,19 +1015,26 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] + r = ref[len(tag_prefix) :] if verbose: print("picking %s" % r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} + return { + "version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": None, + "date": date, + } # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} + return { + "version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, + "error": "no suitable tags", + "date": None, + } @register_vcs_handler("git", "pieces_from_vcs") @@ -1037,8 +1049,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) + out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -1046,10 +1057,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = run_command( + GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root + ) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -1072,17 +1082,16 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] + git_describe = git_describe[: git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) + mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) if not mo: # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%s'" - % describe_out) + pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out return pieces # tag @@ -1091,10 +1100,9 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" - % (full_tag, tag_prefix)) + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] + pieces["closest-tag"] = full_tag[len(tag_prefix) :] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -1105,13 +1113,11 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) + count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) pieces["distance"] = int(count_out) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], - cwd=root)[0].strip() + date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces @@ -1167,16 +1173,19 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): for i in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} + return { + "version": dirname[len(parentdir_prefix) :], + "full-revisionid": None, + "dirty": False, + "error": None, + "date": None, + } else: rootdirs.append(root) root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % - (str(rootdirs), parentdir_prefix)) + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1205,11 +1214,9 @@ def versions_from_file(filename): contents = f.read() except EnvironmentError: raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", - contents, re.M | re.S) + mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: raise NotThisMethod("no version_json in _version.py") return json.loads(mo.group(1)) @@ -1218,8 +1225,7 @@ def versions_from_file(filename): def write_to_version_file(filename, versions): """Write the given version number to the given _version.py file.""" os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, - indent=1, separators=(",", ": ")) + contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) @@ -1251,8 +1257,7 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], - pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered @@ -1366,11 +1371,13 @@ def render_git_describe_long(pieces): def render(pieces, style): """Render the given version pieces into the requested style.""" if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} + return { + "version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None, + } if not style or style == "default": style = "pep440" # the default @@ -1390,9 +1397,13 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} + return { + "version": rendered, + "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], + "error": None, + "date": pieces.get("date"), + } class VersioneerBadRootError(Exception): @@ -1415,8 +1426,7 @@ def get_versions(verbose=False): handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, \ - "please set versioneer.versionfile_source" + assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" versionfile_abs = os.path.join(root, cfg.versionfile_source) @@ -1470,9 +1480,13 @@ def get_versions(verbose=False): if verbose: print("unable to compute version") - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, "error": "unable to compute version", - "date": None} + return { + "version": "0+unknown", + "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", + "date": None, + } def get_version(): @@ -1521,6 +1535,7 @@ def run(self): print(" date: %s" % vers.get("date")) if vers["error"]: print(" error: %s" % vers["error"]) + cmds["version"] = cmd_version # we override "build_py" in both distutils and setuptools @@ -1553,14 +1568,15 @@ def run(self): # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, - cfg.versionfile_build) + target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) + cmds["build_py"] = cmd_build_py if "cx_Freeze" in sys.modules: # cx_freeze enabled? from cx_Freeze.dist import build_exe as _build_exe + # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1581,17 +1597,21 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["build_exe"] = cmd_build_exe del cmds["build_py"] - if 'py2exe' in sys.modules: # py2exe enabled? + if "py2exe" in sys.modules: # py2exe enabled? try: from py2exe.distutils_buildexe import py2exe as _py2exe # py3 except ImportError: @@ -1610,13 +1630,17 @@ def run(self): os.unlink(target_versionfile) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % - {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + cmds["py2exe"] = cmd_py2exe # we override different "sdist" commands for both environments @@ -1643,8 +1667,8 @@ def make_release_tree(self, base_dir, files): # updated value target_versionfile = os.path.join(base_dir, cfg.versionfile_source) print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, - self._versioneer_generated_versions) + write_to_version_file(target_versionfile, self._versioneer_generated_versions) + cmds["sdist"] = cmd_sdist return cmds @@ -1699,11 +1723,9 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, - configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", - file=sys.stderr) + print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: f.write(SAMPLE_CONFIG) print(CONFIG_ERROR, file=sys.stderr) @@ -1712,15 +1734,18 @@ def do_setup(): print(" creating %s" % cfg.versionfile_source) with open(cfg.versionfile_source, "w") as f: LONG = LONG_VERSION_PY[cfg.VCS] - f.write(LONG % {"DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - }) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), - "__init__.py") + f.write( + LONG + % { + "DOLLAR": "$", + "STYLE": cfg.style, + "TAG_PREFIX": cfg.tag_prefix, + "PARENTDIR_PREFIX": cfg.parentdir_prefix, + "VERSIONFILE_SOURCE": cfg.versionfile_source, + } + ) + + ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: with open(ipy, "r") as f: @@ -1762,8 +1787,7 @@ def do_setup(): else: print(" 'versioneer.py' already in MANIFEST.in") if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - cfg.versionfile_source) + print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) with open(manifest_in, "a") as f: f.write("include %s\n" % cfg.versionfile_source) else: From 5cb22ca7b0a00e7e5d24fdcbd470495201cf4299 Mon Sep 17 00:00:00 2001 From: fossabot Date: Mon, 12 Sep 2022 19:37:19 -0500 Subject: [PATCH 283/567] Add license scan report and status (#1106) Signed off by: fossabot Co-authored-by: Jac --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b21c2665d..71bf9b023 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Tableau Server Client (Python) [![Tableau Supported](https://img.shields.io/badge/Support%20Level-Tableau%20Supported-53bd92.svg)](https://www.tableau.com/support-levels-it-and-developer-tools) [![Build Status](https://github.com/tableau/server-client-python/actions/workflows/run-tests.yml/badge.svg)](https://github.com/tableau/server-client-python/actions) +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_shield) Use the Tableau Server Client (TSC) library to increase your productivity as you interact with the Tableau Server REST API. With the TSC library you can do almost everything that you can do with the REST API, including: @@ -15,3 +16,7 @@ To see sample code that works directly with the REST API (in Java, Python, or Po For more information on installing and using TSC, see the documentation: + + +## License +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file From ef169e7786659a22c8a1467b5f5a87c9123f3a3f Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 12 Sep 2022 18:14:15 -0700 Subject: [PATCH 284/567] Update publish-pypi.yml (#1109) --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9b4e842ee..eedb48138 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -24,7 +24,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[test] - python setup.py sdist --formats=gztar bdist_wheel + python -m build git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 From 8684058f94431678d3df4cac5d57112e0c168914 Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Wed, 14 Sep 2022 23:14:37 +0200 Subject: [PATCH 285/567] Fix `filepath` parameter in `publish_{datasource,workbook}` samples (#1112) --- samples/publish_datasource.py | 4 ++-- samples/publish_workbook.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index eecbe7088..8d9e59ea2 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -96,13 +96,13 @@ def main(): if args.async_: # Async publishing, returns a job_item new_job = server.datasources.publish( - new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds, as_job=True + new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( - new_datasource, args.filepath, publish_mode, connection_credentials=new_conn_creds + new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 3cc27c582..a0bf1794b 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): if args.as_job: new_job = server.workbooks.publish( new_workbook, - args.filepath, + args.file, overwrite_true, connections=all_connections, as_job=args.as_job, @@ -90,7 +90,7 @@ def main(): else: new_workbook = server.workbooks.publish( new_workbook, - args.filepath, + args.file, overwrite_true, connections=all_connections, as_job=args.as_job, From d1b7a6c89d1125a7725e51789d95869eb4fa287d Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 19 Sep 2022 14:01:25 -0500 Subject: [PATCH 286/567] Add build package to pip install (#1113) * Add build package to pip install * Add build check to tests --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index eedb48138..33438bed8 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -23,7 +23,7 @@ jobs: - name: Build dist files run: | python -m pip install --upgrade pip - pip install -e .[test] + pip install -e .[test] build python -m build git describe --tag --dirty --always - name: Publish distribution 📦 to Test PyPI diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b83af5a4b..10df02c04 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -23,9 +23,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[test] + pip install -e .[test] build - name: Test with pytest if: always() run: | pytest test + + - name: Test build + if: always() + run: | + python -m build \ No newline at end of file From f653e15b582ae2ea7fc76f423f178d430e2a30ed Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 20 Sep 2022 02:13:05 -0700 Subject: [PATCH 287/567] Development (#1114) git cleanup merge: make the development branch really believe it is up to date with master --- setup.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 60d8fe6b8..dfd43ae8a 100644 --- a/setup.py +++ b/setup.py @@ -14,9 +14,11 @@ version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), # not yet sure how to move this to pyproject.toml - packages=['tableauserverclient', - 'tableauserverclient.helpers', - 'tableauserverclient.models', - 'tableauserverclient.server', - 'tableauserverclient.server.endpoint'], + packages=[ + "tableauserverclient", + "tableauserverclient.helpers", + "tableauserverclient.models", + "tableauserverclient.server", + "tableauserverclient.server.endpoint", + ], ) From ef9e7fd23b70bf64beb3e5db41fc5d1dd9e5c1f5 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 28 Sep 2022 00:41:42 -0700 Subject: [PATCH 288/567] Add custom session injection, Fix bug for http_options (#1119) * ssl-verify is an option, not a header * Allow injection of session_factory to allow use of a custom session * show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni --- contributing.md | 10 ++- samples/create_group.py | 2 +- samples/initialize_server.py | 6 +- tableauserverclient/__init__.py | 1 + tableauserverclient/models/flow_item.py | 4 - .../models/permissions_item.py | 2 +- tableauserverclient/models/revision_item.py | 5 +- .../models/server_info_item.py | 9 ++- tableauserverclient/models/site_item.py | 1 - tableauserverclient/models/tableau_auth.py | 2 +- tableauserverclient/server/__init__.py | 1 + .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 34 +++----- .../server/endpoint/server_info_endpoint.py | 21 ++++- tableauserverclient/server/server.py | 62 +++++++++------ test/http/test_http_requests.py | 79 +++++++++++++++++++ test/test_view.py | 2 +- 17 files changed, 174 insertions(+), 69 deletions(-) create mode 100644 test/http/test_http_requests.py diff --git a/contributing.md b/contributing.md index 90fbdc4f0..41c339cb6 100644 --- a/contributing.md +++ b/contributing.md @@ -66,18 +66,22 @@ pytest pip install . ``` +### Debugging Tools +See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) + + ### Before Committing Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. ```shell # this will run the formatter without making changes -black --line-length 120 tableauserverclient test samples --check +black . --check # this will format the directory and code for you -black --line-length 120 tableauserverclient test samples +black . # this will run type checking pip install mypy -mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test +mypy tableauserverclient test samples ``` diff --git a/samples/create_group.py b/samples/create_group.py index 50d84a187..d5cf712db 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -46,7 +46,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # this code shows 3 different error codes that mean "resource is already in collection" # 409009: group already exists on server diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 586011120..21b243013 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -56,7 +56,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...").format(args.site_id) + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -64,7 +64,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...").format(args.site_id) + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -87,7 +87,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...").format(args.project) + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 394184120..7c1e6d705 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -39,6 +39,7 @@ ) from .server import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d957f5e14..18f0ecae2 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -93,10 +93,6 @@ def description(self, value: str) -> None: def project_name(self) -> Optional[str]: return self._project_name - @property - def flow_type(self): # What is this? It doesn't seem to get set anywhere. - return self._flow_type - @property def updated_at(self) -> Optional["datetime.datetime"]: return self._updated_at diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1c1e9db4d..74b167e9d 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -69,7 +69,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: ", capability_xml) + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 024d45edd..a49be88a7 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -53,8 +53,9 @@ def user_name(self) -> Optional[str]: def __repr__(self): return ( - "" - ).format(**self.__dict__) + "".format(**self.__dict__) + ) @classmethod def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index d0ac5d292..350ae3a0d 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,6 @@ +import warnings +import xml + from defusedxml.ElementTree import fromstring @@ -32,7 +35,11 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - parsed_response = fromstring(resp) + try: + parsed_response = fromstring(resp) + except xml.etree.ElementTree.ParseError as error: + warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 3deda03e2..8c9e8fe8e 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1,7 +1,6 @@ import warnings import xml.etree.ElementTree as ET -from distutils.version import Version from defusedxml.ElementTree import fromstring from .property_decorators import ( property_is_enum, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index f373a84ab..6ad0fda5a 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -9,7 +9,7 @@ def credentials(self): +"This method returns values to set as an attribute on the credentials element of the request" def __repr__(self): - display = "All Credentials types must have a debug display that does not print secrets" + return "All Credentials types must have a debug display that does not print secrets" def deprecate_site_attribute(): diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 25abb3c9a..84d118a2e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -2,6 +2,7 @@ from .request_factory import RequestFactory from .request_options import ( CSVRequestOptions, + ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 1fab7ac4b..aa9d73f18 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -116,7 +116,7 @@ def update_table_default_permissions(self, item): @api(version="3.5") def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permissions(item, Resource.Table) + self._default_permissions.delete_default_permission(item, Resource.Table) @api(version="3.5") def populate_dqw(self, item): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 378c84746..3cdc49322 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,6 +1,6 @@ import requests import logging -from distutils.version import LooseVersion as Version +from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError from typing import Any, Callable, Dict, Optional, TYPE_CHECKING @@ -11,9 +11,12 @@ NonXMLResponseError, EndpointUnavailableError, ) -from .. import endpoint from ..query import QuerySet from ... import helpers +from ..._version import get_versions + +__TSC_VERSION__ = get_versions()["version"] +del get_versions logger = logging.getLogger("tableau.endpoint") @@ -22,34 +25,25 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" +USERAGENT_HEADER = "User-Agent" + if TYPE_CHECKING: from ..server import Server from requests import Response -_version_header: Optional[str] = None - - class Endpoint(object): def __init__(self, parent_srv: "Server"): - global _version_header self.parent_srv = parent_srv @staticmethod def _make_common_headers(auth_token, content_type): - global _version_header - - if not _version_header: - from ..server import __TSC_VERSION__ - - _version_header = __TSC_VERSION__ - headers = {} if auth_token is not None: headers["x-tableau-auth"] = auth_token if content_type is not None: headers["content-type"] = content_type - headers["User-Agent"] = "Tableau Server Client/{}".format(_version_header) + headers["User-Agent"] = "Tableau Server Client/{}".format(__TSC_VERSION__) return headers def _make_request( @@ -62,9 +56,9 @@ def _make_request( parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = parameters or {} - parameters.update(self.parent_srv.http_options) if "headers" not in parameters: parameters["headers"] = {} + parameters.update(self.parent_srv.http_options) parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) if content is not None: @@ -89,14 +83,12 @@ def _check_status(self, server_response, url: str = None): if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: - # todo: is an error reliably of content-type application/xml? try: raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: - # This will happen if we get a non-success HTTP code that - # doesn't return an xml error object (like metadata endpoints or 503 pages) - # we convert this to a better exception and pass through the raw - # response body + # This will happen if we get a non-success HTTP code that doesn't return an xml error object + # e.g metadata endpoints, 503 pages, totally different servers + # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here @@ -194,7 +186,7 @@ def api(version): def _decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): - self.parent_srv.assert_at_least_version(version, "endpoint") + self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) return wrapper diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 2036d8d5e..943aabee6 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -12,6 +12,19 @@ class ServerInfo(Endpoint): + def __init__(self, server): + self.parent_srv = server + self._info = None + + @property + def serverInfo(self): + if not self._info: + self.get() + return self._info + + def __repr__(self): + return "".format(self.serverInfo) + @property def baseurl(self): return "{0}/serverInfo".format(self.parent_srv.baseurl) @@ -23,10 +36,10 @@ def get(self): server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: if e.code == "404003": - raise ServerInfoEndpointNotFoundError + raise ServerInfoEndpointNotFoundError(e) if e.code == "404001": - raise EndpointUnavailableError + raise EndpointUnavailableError(e) raise e - server_info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) - return server_info + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + return self._info diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index c82f4a6e2..ebe11dac7 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,3 +1,5 @@ +import warnings + import requests import urllib3 @@ -37,11 +39,6 @@ from ..namespace import Namespace -from .._version import get_versions - -__TSC_VERSION__ = get_versions()["version"] -del get_versions - _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", "9.3": "2.2", @@ -51,7 +48,6 @@ } minimum_supported_server_version = "2.3" default_server_version = "2.3" -client_version_header = "X-TableauServerClient-Version" class Server(object): @@ -60,15 +56,14 @@ class PublishMode: Overwrite = "Overwrite" CreateNew = "CreateNew" - def __init__(self, server_address, use_server_version=False, http_options=None): - self._server_address = server_address + def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None self._site_id = None self._user_id = None - self._session = requests.Session() - self._http_options = dict() - self.version = default_server_version + self._server_address = server_address + self._session_factory = session_factory or requests.session + self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -95,32 +90,48 @@ def __init__(self, server_address, use_server_version=False, http_options=None): self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) - # must set this before calling use_server_version, because that's a server call + self._session = self._session_factory() + self._http_options = dict() # must set this before making a server call if http_options: self.add_http_options(http_options) - self.add_http_version_header() + self.validate_server_connection() + + self.version = default_server_version if use_server_version: - self.use_server_version() + self.use_server_version() # this makes a server call - def add_http_options(self, options_dict): - self._http_options.update(options_dict) - if options_dict.get("verify") == False: - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + def validate_server_connection(self): + try: + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + except Exception as req_ex: + warnings.warn("Invalid server initialization\n {}".format(req_ex.__str__()), UserWarning) + print("==================") - def add_http_version_header(self): - if not self._http_options[client_version_header]: - self._http_options.update({client_version_header: __TSC_VERSION__}) + def __repr__(self): + return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + + def add_http_options(self, options_dict: dict): + try: + self._http_options.update(options_dict) + if "verify" in options_dict.keys() and self._http_options.get("verify") is False: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + # would be nice if you could turn them back on + except BaseException as be: + print(be) + # expected errors on invalid input: + # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' + # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() - self.add_http_version_header() def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None - self._session = requests.Session() + self._session = self._session_factory() def _set_auth(self, site_id, user_id, auth_token): self._site_id = site_id @@ -141,9 +152,10 @@ def _determine_highest_version(self): version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError: version = self._get_legacy_version() + except BaseException: + version = self._get_legacy_version() - finally: - self.version = old_version + self.version = old_version return version diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py new file mode 100644 index 000000000..a5f4f4669 --- /dev/null +++ b/test/http/test_http_requests.py @@ -0,0 +1,79 @@ +import tableauserverclient as TSC +import unittest +import requests + +from requests_mock import adapter, mock +from requests.exceptions import MissingSchema + + +class ServerTests(unittest.TestCase): + def test_init_server_model_empty_throws(self): + with self.assertRaises(TypeError): + server = TSC.Server() + + def test_init_server_model_bad_server_name_complains(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url") + + def test_init_server_model_valid_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("http://fake-url") + + def test_init_server_model_valid_https_server_name_works(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("https://fake-url") + + def test_init_server_model_bad_server_name_not_version_check(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False) + + def test_init_server_model_bad_server_name_do_version_check(self): + with self.assertRaises(MissingSchema): + server = TSC.Server("fake-url", use_server_version=True) + + def test_init_server_model_bad_server_name_not_version_check_random_options(self): + # by default, it will just set the version to 2.3 + server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) + + def test_init_server_model_bad_server_name_not_version_check_real_options(self): + server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) + + def test_http_options_skip_ssl_works(self): + http_options = {"verify": False} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + def test_http_options_multiple_options_works(self): + http_options = {"verify": False, "birdname": "Parrot"} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + # ValueError: dictionary update sequence element #0 has length 1; 2 is required + def test_http_options_multiple_dicts_fails(self): + http_options_1 = {"verify": False} + http_options_2 = {"birdname": "Parrot"} + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options([http_options_1, http_options_2]) + + # TypeError: cannot convert dictionary update sequence element #0 to a sequence + def test_http_options_not_sequence_fails(self): + server = TSC.Server("http://fake-url") + with self.assertRaises(ValueError): + server.add_http_options({1, 2, 3}) + + +class SessionTests(unittest.TestCase): + test_header = {"x-test": "true"} + + @staticmethod + def session_factory(): + session = requests.session() + session.headers.update(SessionTests.test_header) + return session + + def test_session_factory_adds_headers(self): + test_request_bin = "http://capture-this-with-mock.com" + with mock() as m: + m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) + server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) diff --git a/test/test_view.py b/test/test_view.py index 3562650d1..f5d3db47b 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -294,7 +294,7 @@ def test_populate_excel(self) -> None: m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) single_view = TSC.ViewItem() single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) + request_option = TSC.ExcelRequestOptions(maxage=1) self.server.views.populate_excel(single_view, request_option) excel_file = b"".join(single_view.excel) From 4873a5821177f22d319ab7ba28f6d9e18ec7aeaa Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 10 Oct 2022 19:42:45 -0700 Subject: [PATCH 289/567] Patch fix: 0.23.1 (#1129) * Allow injection of session_factory to allow use of a custom session * Jac/show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) * fix behavior when url has no protocol (#1125) * smoke test for pypi * Add permission control for Data Roles and Metrics (Issue #1063) (#1120) Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> --- .github/workflows/pypi-smoke-tests.yml | 36 +++++++++++++ LICENSE | 2 +- samples/smoke_test.py | 8 +++ tableauserverclient/__init__.py | 1 + tableauserverclient/models/tableau_auth.py | 2 +- tableauserverclient/models/tableau_types.py | 2 + .../server/endpoint/auth_endpoint.py | 13 ++++- .../server/endpoint/endpoint.py | 23 ++++---- .../server/endpoint/projects_endpoint.py | 24 +++++++++ tableauserverclient/server/server.py | 24 ++++++--- test/http/test_http_requests.py | 52 ++++++++++++++++--- versioneer.py | 0 12 files changed, 156 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/pypi-smoke-tests.yml create mode 100644 samples/smoke_test.py mode change 100755 => 100644 versioneer.py diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml new file mode 100644 index 000000000..eb6406573 --- /dev/null +++ b/.github/workflows/pypi-smoke-tests.yml @@ -0,0 +1,36 @@ +# This workflow will install TSC from pypi and validate that it runs. For more information see: +# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Pypi smoke tests + +on: + workflow_dispatch: + schedule: + - cron: 0 11 * * * # Every day at 11AM UTC (7AM EST) + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.x'] + + runs-on: ${{ matrix.os }} + + steps: + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: pip install + run: | + pip uninstall tableauserverclient + pip install tableauserverclient + - name: Launch app + run: | + python -c "import tableauserverclient as TSC + server = TSC.Server('http://example.com', use_server_version=False)" diff --git a/LICENSE b/LICENSE index 6222b2e80..22f90640f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 Tableau +Copyright (c) 2022 Tableau Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/samples/smoke_test.py b/samples/smoke_test.py new file mode 100644 index 000000000..f2dad1048 --- /dev/null +++ b/samples/smoke_test.py @@ -0,0 +1,8 @@ +# This sample verifies that tableau server client is installed +# and you can run it. It also shows the version of the client. + +import tableauserverclient as TSC + +server = TSC.Server("Fake-Server-Url", use_server_version=False) +print("Client details:") +print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7c1e6d705..212540d84 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,3 +1,4 @@ +from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from .models import ( BackgroundJobItem, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 6ad0fda5a..24ba1d682 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -65,6 +65,6 @@ def credentials(self): } def __repr__(self): - return "".format( + return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index feaf02873..6ed77318f 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -11,9 +11,11 @@ class Resource: Database = "database" + Datarole = "datarole" Datasource = "datasource" Flow = "flow" Lens = "lens" + Metric = "metric" Project = "project" Table = "table" View = "view" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6baf399ed..68d75eaa8 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -28,7 +28,18 @@ def baseurl(self): def sign_in(self, auth_req): url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) - server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options) + server_response = self.parent_srv.session.post( + url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False + ) + # manually handle a redirect so that we send the correct POST request instead of GET + # this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com + if server_response.status_code == 301: + server_response = self.parent_srv.session.post( + server_response.headers["Location"], + data=signin_req, + **self.parent_srv.http_options, + allow_redirects=False, + ) self.parent_srv._namespace.detect(server_response.content) self._check_status(server_response, url) parsed_response = fromstring(server_response.content) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3cdc49322..a836b000d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -12,11 +12,11 @@ EndpointUnavailableError, ) from ..query import QuerySet -from ... import helpers -from ..._version import get_versions +from ... import helpers, get_versions -__TSC_VERSION__ = get_versions()["version"] -del get_versions +if TYPE_CHECKING: + from ..server import Server + from requests import Response logger = logging.getLogger("tableau.endpoint") @@ -25,11 +25,9 @@ XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" -USERAGENT_HEADER = "User-Agent" - -if TYPE_CHECKING: - from ..server import Server - from requests import Response +CONTENT_TYPE_HEADER = "content-type" +TABLEAU_AUTH_HEADER = "x-tableau-auth" +USER_AGENT_HEADER = "User-Agent" class Endpoint(object): @@ -38,12 +36,13 @@ def __init__(self, parent_srv: "Server"): @staticmethod def _make_common_headers(auth_token, content_type): + _client_version: Optional[str] = get_versions()["version"] headers = {} if auth_token is not None: - headers["x-tableau-auth"] = auth_token + headers[TABLEAU_AUTH_HEADER] = auth_token if content_type is not None: - headers["content-type"] = content_type - headers["User-Agent"] = "Tableau Server Client/{}".format(__TSC_VERSION__) + headers[CONTENT_TYPE_HEADER] = content_type + headers[USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) return headers def _make_request( diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index e268d2011..7ccdcd775 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -99,6 +99,14 @@ def populate_workbook_default_permissions(self, item): def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datasource) + @api(version="3.2") + def populate_metric_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Metric) + + @api(version="3.4") + def populate_datarole_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Datarole) + @api(version="3.4") def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Flow) @@ -115,6 +123,14 @@ def update_workbook_default_permissions(self, item, rules): def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) + @api(version="3.2") + def update_metric_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) + + @api(version="3.4") + def update_datarole_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) + @api(version="3.4") def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @@ -131,6 +147,14 @@ def delete_workbook_default_permissions(self, item, rule): def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) + @api(version="3.2") + def delete_metric_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Metric) + + @api(version="3.4") + def delete_datarole_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) + @api(version="3.4") def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Flow) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ebe11dac7..5e2dacf33 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,9 +1,10 @@ +import logging import warnings import requests import urllib3 -from defusedxml.ElementTree import fromstring +from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version from .endpoint import ( Sites, @@ -61,7 +62,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._site_id = None self._user_id = None - self._server_address = server_address + self._server_address: str = server_address self._session_factory = session_factory or requests.session self.auth = Auth(self) @@ -103,10 +104,13 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_server_connection(self): try: - self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + if not self._server_address.startswith("https://") and not self._server_address.startswith("https://"): + self._server_address = "https://" + self._server_address + self._session.prepare_request( + requests.Request("GET", url=self._server_address, params=self._http_options) + ) except Exception as req_ex: - warnings.warn("Invalid server initialization\n {}".format(req_ex.__str__()), UserWarning) - print("==================") + raise ValueError("Invalid server initialization", req_ex) def __repr__(self): return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) @@ -140,7 +144,13 @@ def _set_auth(self, site_id, user_id, auth_token): def _get_legacy_version(self): response = self._session.get(self.server_address + "/auth?format=xml") - info_xml = fromstring(response.content) + try: + info_xml = fromstring(response.content) + except ParseError as parseError: + logging.getLogger("TSC.server").info( + "Could not read server version info. The server may not be running or configured." + ) + return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 return version @@ -164,8 +174,6 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - import warnings - warnings.warn("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index a5f4f4669..e96879277 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -1,22 +1,39 @@ import tableauserverclient as TSC import unittest import requests +import requests_mock -from requests_mock import adapter, mock +from unittest import mock from requests.exceptions import MissingSchema +# This method will be used by the mock to replace requests.get +def mocked_requests_get(*args, **kwargs): + class MockResponse: + def __init__(self, status_code): + self.content = ( + "" + "" + "0.31" + "0.31" + "2022.3" + "" + "" + ) + self.status_code = status_code + + return MockResponse(200) + + class ServerTests(unittest.TestCase): def test_init_server_model_empty_throws(self): with self.assertRaises(TypeError): server = TSC.Server() - def test_init_server_model_bad_server_name_complains(self): - # by default, it will just set the version to 2.3 + def test_init_server_model_no_protocol_defaults_htt(self): server = TSC.Server("fake-url") def test_init_server_model_valid_server_name_works(self): - # by default, it will just set the version to 2.3 server = TSC.Server("http://fake-url") def test_init_server_model_valid_https_server_name_works(self): @@ -24,18 +41,18 @@ def test_init_server_model_valid_https_server_name_works(self): server = TSC.Server("https://fake-url") def test_init_server_model_bad_server_name_not_version_check(self): - # by default, it will just set the version to 2.3 server = TSC.Server("fake-url", use_server_version=False) def test_init_server_model_bad_server_name_do_version_check(self): - with self.assertRaises(MissingSchema): + with self.assertRaises(requests.exceptions.ConnectionError): server = TSC.Server("fake-url", use_server_version=True) def test_init_server_model_bad_server_name_not_version_check_random_options(self): - # by default, it will just set the version to 2.3 + # with self.assertRaises(MissingSchema): server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) def test_init_server_model_bad_server_name_not_version_check_real_options(self): + # with self.assertRaises(ValueError): server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) def test_http_options_skip_ssl_works(self): @@ -62,6 +79,25 @@ def test_http_options_not_sequence_fails(self): with self.assertRaises(ValueError): server.add_http_options({1, 2, 3}) + def test_validate_connection_http(self): + url = "http://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(url, server.server_address) + + def test_validate_connection_https(self): + url = "https://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(url, server.server_address) + + def test_validate_connection_no_protocol(self): + url = "cookies.com" + fixed_url = "http://cookies.com" + server = TSC.Server(url) + server.validate_server_connection() + self.assertEqual(fixed_url, server.server_address) + class SessionTests(unittest.TestCase): test_header = {"x-test": "true"} @@ -74,6 +110,6 @@ def session_factory(): def test_session_factory_adds_headers(self): test_request_bin = "http://capture-this-with-mock.com" - with mock() as m: + with requests_mock.mock() as m: m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) diff --git a/versioneer.py b/versioneer.py old mode 100755 new mode 100644 From 83e1069520669ad18f2cfca384bc74784c3bb75a Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 13 Oct 2022 20:53:17 -0700 Subject: [PATCH 290/567] Update publish-pypi.yml (#1130) * Update publish-pypi.yml Change from user saying to use prod to using prod whenever it runs on master --- .github/workflows/publish-pypi.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 33438bed8..63610be70 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -26,13 +26,15 @@ jobs: pip install -e .[test] build python -m build git describe --tag --dirty --always - - name: Publish distribution 📦 to Test PyPI + + - name: Publish distribution 📦 to Test PyPI # always run uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ + - name: Publish distribution 📦 to PyPI - if: github.ref == 'refs/heads/master' + if: $GITHUB_REF == 'refs/heads/master' uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From fb3cd656e33ffd48fab365d17ce60c2766021ebf Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 22 Dec 2022 11:49:52 -0800 Subject: [PATCH 291/567] Fix publishing and mypy actions (#1158) * Fix usage of env var in publishing action file * Support old implicit optional behavior for mypy (https://github.com/hauntsaninja/no_implicit_optional) --- .github/workflows/meta-checks.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 7ae27e6b8..3fcb852d1 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -32,4 +32,4 @@ jobs: - name: Run Mypy tests if: always() run: | - mypy --show-error-codes --disable-error-code misc --disable-error-code import tableauserverclient test + mypy --show-error-codes --disable-error-code misc --disable-error-code import --implicit-optional tableauserverclient test diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 63610be70..13e40899a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: $GITHUB_REF == 'refs/heads/master' + if: ${GITHUB_REF} == 'refs/heads/master' uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From c2ff85936246a69713b668f4210d5a3152feae51 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 22 Dec 2022 13:01:50 -0800 Subject: [PATCH 292/567] Fix github reference for publishing action (#1159) --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 13e40899a..34a7aa448 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: ${GITHUB_REF} == 'refs/heads/master' + if: ${{ github.ref == 'refs/heads/master' }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From db441bd39c623812541e77315a46968675e209d8 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 22 Dec 2022 14:36:08 -0800 Subject: [PATCH 293/567] run if master OR tag (#1160) --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 34a7aa448..b8a70e9c5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -34,7 +34,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: ${{ github.ref == 'refs/heads/master' }} + if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} From 97747b7b253d659de7871e0b3f20c0a1702b4c2a Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Sat, 7 Jan 2023 00:38:23 +0100 Subject: [PATCH 294/567] Add logic to retrieve Datarole and Metric permissions (#1163) --- tableauserverclient/models/project_item.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index acb14ce91..a8430bfd0 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -34,6 +34,8 @@ def __init__( self._default_datasource_permissions = None self._default_flow_permissions = None self._default_lens_permissions = None + self._default_datarole_permissions = None + self._default_metric_permissions = None @property def content_permissions(self): @@ -79,6 +81,20 @@ def default_lens_permissions(self): raise UnpopulatedPropertyError(error) return self._default_lens_permissions() + @property + def default_datarole_permissions(self): + if self._default_datarole_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_datarole_permissions() + + @property + def default_metric_permissions(self): + if self._default_metric_permissions is None: + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._default_metric_permissions() + @property def id(self) -> Optional[str]: return self._id From a29ba6cac78b77db0c22d84b539fc6db7fa1132a Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 13 Feb 2023 21:10:44 -0800 Subject: [PATCH 295/567] Create code-coverage.yml (#1190) copied from /tableau/tabcmd repo --- .github/workflows/code-coverage.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/code-coverage.yml diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 000000000..d393a06d5 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,38 @@ +name: Check Test Coverage + +on: + pull_request: + branches: + - development + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.10'] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + # https://github.com/marketplace/actions/pytest-coverage-comment + - name: Generate coverage report + run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + + - name: Comment on pull request with coverage + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt From 514cc1348bfc17eea8fae32743f5e95784b0e259 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 14 Feb 2023 12:47:28 -0800 Subject: [PATCH 296/567] push code for 0.24 (#1168) * Allow injection of sessions (#1111) * show server info (#1118) * Fix bug in exposing ExcelRequestOptions and test (#1123) * Fix a few pylint errors (#1124) * fix behavior when url has no protocol (#1125) * Add permission control for Data Roles and Metrics (Issue #1063) (#1120) * add option to pass specific datasources (#1150) * allow user agent to be set by caller (#1166) * Fix issues with connections publishing workbooks (#1171) * Allow download to file-like objects (#1172) * Add updated_at to JobItem class (#1182) * fix revision references where xml returned does not match docs (#1176) * Do not create empty connections list (#1178) --------- Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Co-authored-by: Stu Tomlinson Co-authored-by: Jeremy Harris --- tableauserverclient/models/datasource_item.py | 2 +- tableauserverclient/models/job_item.py | 10 +- tableauserverclient/models/revision_item.py | 8 +- tableauserverclient/models/site_item.py | 6 +- tableauserverclient/models/workbook_item.py | 2 +- .../server/endpoint/datasources_endpoint.py | 97 ++++++---------- .../server/endpoint/endpoint.py | 54 +++++---- .../server/endpoint/exceptions.py | 3 +- .../server/endpoint/flows_endpoint.py | 106 +++++++++++++----- .../server/endpoint/permissions_endpoint.py | 4 +- .../server/endpoint/schedules_endpoint.py | 8 +- .../server/endpoint/users_endpoint.py | 12 +- .../server/endpoint/workbooks_endpoint.py | 96 +++++++--------- tableauserverclient/server/request_factory.py | 24 ++-- tableauserverclient/server/server.py | 21 ++-- test/assets/SampleFlow.tfl | Bin 0 -> 1884 bytes test/assets/datasource_revision.xml | 10 +- test/assets/flow_publish.xml | 10 ++ test/assets/workbook_revision.xml | 10 +- test/http/test_http_requests.py | 6 +- test/test_datasource.py | 12 ++ test/test_endpoint.py | 18 +++ test/test_flow.py | 83 +++++++++++++- test/test_job.py | 2 + test/test_workbook.py | 61 ++++++++++ 25 files changed, 439 insertions(+), 226 deletions(-) create mode 100644 test/assets/SampleFlow.tfl create mode 100644 test/assets/flow_publish.xml diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 37ec1449a..4a7a74c4b 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -34,7 +34,7 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" - def __init__(self, project_id: str, name: str = None) -> None: + def __init__(self, project_id: str, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 39562cd45..a7490e705 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -34,6 +34,7 @@ def __init__( workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, + updated_at: Optional["datetime.datetime"] = None, ): self._id = id_ self._type = job_type @@ -47,6 +48,7 @@ def __init__( self._workbook_id = workbook_id self._datasource_id = datasource_id self._flow_run = flow_run + self._updated_at = updated_at @property def id(self) -> str: @@ -113,9 +115,13 @@ def flow_run(self): def flow_run(self, value): self._flow_run = value + @property + def updated_at(self) -> Optional["datetime.datetime"]: + return self._updated_at + def __repr__(self): return ( - "".format(**self.__dict__) ) @@ -144,6 +150,7 @@ def _parse_element(cls, element, ns): datasource = element.find(".//t:datasource[@id]", namespaces=ns) datasource_id = datasource.get("id") if datasource is not None else None flow_run = None + updated_at = parse_datetime(element.get("updatedAt", None)) for flow_job in element.findall(".//t:runFlowJobType", namespaces=ns): flow_run = FlowRunItem() flow_run._id = flow_job.get("flowRunId", None) @@ -163,6 +170,7 @@ def _parse_element(cls, element, ns): workbook_id, datasource_id, flow_run, + updated_at, ) diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a49be88a7..600d73168 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -67,10 +67,10 @@ def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: revision_item._resource_id = resource_item.id revision_item._resource_name = resource_item.name revision_item._revision_number = revision_xml.get("revisionNumber", None) - revision_item._current = string_to_bool(revision_xml.get("isCurrent", "")) - revision_item._deleted = string_to_bool(revision_xml.get("isDeleted", "")) - revision_item._created_at = parse_datetime(revision_xml.get("createdAt", None)) - for user in revision_xml.findall(".//t:user", namespaces=ns): + revision_item._current = string_to_bool(revision_xml.get("current", "")) + revision_item._deleted = string_to_bool(revision_xml.get("deleted", "")) + revision_item._created_at = parse_datetime(revision_xml.get("publishedAt", None)) + for user in revision_xml.findall(".//t:publisher", namespaces=ns): revision_item._user_id = user.get("id", None) revision_item._user_name = user.get("name", None) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 8c9e8fe8e..e6bc3af24 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -50,9 +50,9 @@ def __init__( self, name: str, content_url: str, - admin_mode: str = None, - user_quota: int = None, - storage_quota: int = None, + admin_mode: Optional[str] = None, + user_quota: Optional[int] = None, + storage_quota: Optional[int] = None, disable_subscriptions: bool = False, subscribe_others_enabled: bool = True, revision_history_enabled: bool = False, diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 0d18e770d..6d9a21b6b 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -33,7 +33,7 @@ class WorkbookItem(object): - def __init__(self, project_id: str, name: str = None, show_tabs: bool = False) -> None: + def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 022523aa4..9df7edfc8 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -31,22 +31,9 @@ ) from ...models import ConnectionCredentials, RevisionItem from ...models.job_item import JobItem -from ...models import ConnectionCredentials -io_types = (io.BytesIO, io.BufferedReader) - -from pathlib import Path -from typing import ( - List, - Mapping, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) - -io_types = (io.BytesIO, io.BufferedReader) +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -61,8 +48,10 @@ from .schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] -FileObject = Union[io.BufferedReader, io.BytesIO] -PathOrFile = Union[FilePath, FileObject] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Datasources(QuerysetEndpoint): @@ -80,7 +69,7 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") - def get(self, req_options: RequestOptions = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -135,39 +124,11 @@ def delete(self, datasource_id: str) -> None: def download( self, datasource_id: str, - filepath: FilePath = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, ) -> str: - if not datasource_id: - error = "Datasource ID undefined." - raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, datasource_id) - - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract - - if not include_extract: - url += "?includeExtract=False" - - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - - logger.info("Downloaded datasource to {0} (ID: {1})".format(download_path, datasource_id)) - return os.path.abspath(download_path) + return self.download_revision(datasource_id, None, filepath, include_extract, no_extract) # Update datasource @api(version="2.0") @@ -232,10 +193,10 @@ def delete_extract(self, datasource_item: DatasourceItem) -> None: def publish( self, datasource_item: DatasourceItem, - file: PathOrFile, + file: PathOrFileR, mode: str, - connection_credentials: ConnectionCredentials = None, - connections: Sequence[ConnectionItem] = None, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: @@ -255,8 +216,7 @@ def publish( error = "Only {} files can be published as datasources.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - elif isinstance(file, io_types): - + elif isinstance(file, io_types_r): if not datasource_item.name: error = "Datasource item must have a name when passing a file object" raise ValueError(error) @@ -302,7 +262,7 @@ def publish( if isinstance(file, (Path, str)): with open(file, "rb") as f: file_contents = f.read() - elif isinstance(file, io_types): + elif isinstance(file, io_types_r): file_contents = file.read() else: raise TypeError("file should be a filepath or file object.") @@ -433,14 +393,17 @@ def download_revision( self, datasource_id: str, revision_number: str, - filepath: Optional[PathOrFile] = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, - ) -> str: + ) -> PathOrFileW: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + if revision_number is None: + url = "{0}/{1}/content".format(self.baseurl, datasource_id) + else: + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) if no_extract is False or no_extract is True: import warnings @@ -455,18 +418,22 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, download_path, datasource_id) + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) ) - return os.path.abspath(download_path) + return return_path @api(version="2.3") def delete_revision(self, datasource_id: str, revision_number: str) -> None: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index a836b000d..b1a42b20c 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,7 +3,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping from .exceptions import ( ServerResponseError, @@ -35,15 +35,35 @@ def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @staticmethod - def _make_common_headers(auth_token, content_type): - _client_version: Optional[str] = get_versions()["version"] - headers = {} + def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + parameters = parameters or {} + parameters.update(http_options) + if "headers" not in parameters: + parameters["headers"] = {} + if auth_token is not None: - headers[TABLEAU_AUTH_HEADER] = auth_token + parameters["headers"][TABLEAU_AUTH_HEADER] = auth_token if content_type is not None: - headers[CONTENT_TYPE_HEADER] = content_type - headers[USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) - return headers + parameters["headers"][CONTENT_TYPE_HEADER] = content_type + + Endpoint.set_user_agent(parameters) + if content is not None: + parameters["data"] = content + return parameters or {} + + @staticmethod + def set_user_agent(parameters): + if USER_AGENT_HEADER not in parameters["headers"]: + if USER_AGENT_HEADER in parameters: + parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] + else: + # only set the TSC user agent if not already populated + _client_version: Optional[str] = get_versions()["version"] + parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + + # result: parameters["headers"]["User-Agent"] is set + # return explicitly for testing only + return parameters def _make_request( self, @@ -54,18 +74,14 @@ def _make_request( content_type: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, ) -> "Response": - parameters = parameters or {} - if "headers" not in parameters: - parameters["headers"] = {} - parameters.update(self.parent_srv.http_options) - parameters["headers"].update(Endpoint._make_common_headers(auth_token, content_type)) - - if content is not None: - parameters["data"] = content + parameters = Endpoint.set_parameters( + self.parent_srv.http_options, auth_token, content, content_type, parameters + ) - logger.debug("request {}, url: {}".format(method.__name__, url)) + logger.debug("request {}, url: {}".format(method, url)) if content: - logger.debug("request content: {}".format(helpers.strings.redact_xml(content[:1000]))) + redacted = helpers.strings.redact_xml(content[:1000]) + logger.debug("request content: {}".format(redacted)) server_response = method(url, **parameters) self._check_status(server_response, url) @@ -78,7 +94,7 @@ def _make_request( return server_response - def _check_status(self, server_response, url: str = None): + def _check_status(self, server_response, url: Optional[str] = None): if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 3ce0d5e92..d7b1d5ad2 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,4 +1,5 @@ from defusedxml.ElementTree import fromstring +from typing import Optional class TableauError(Exception): @@ -33,7 +34,7 @@ def from_response(cls, resp, ns, url=None): class InternalServerError(TableauError): - def __init__(self, server_response, request_url: str = None): + def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code self.content = server_response.content self.url = request_url or "server" diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2c54d17c4..5b182111b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,8 +1,10 @@ import cgi import copy +import io import logging import os from contextlib import closing +from pathlib import Path from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from .dqw_endpoint import _DataQualityWarningEndpoint @@ -11,9 +13,17 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import to_filename, make_download_path +from ...filesys_helpers import ( + to_filename, + make_download_path, + get_file_type, + get_file_object_size, +) from ...models.job_item import JobItem +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -29,6 +39,10 @@ FilePath = Union[str, os.PathLike] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Flows(QuerysetEndpoint): @@ -94,7 +108,7 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") - def download(self, flow_id: str, filepath: FilePath = None) -> str: + def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -102,16 +116,20 @@ def download(self, flow_id: str, filepath: FilePath = None) -> str: with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - - logger.info("Downloaded flow to {0} (ID: {1})".format(download_path, flow_id)) - return os.path.abspath(download_path) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) + + logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + return return_path # Update flow @api(version="3.3") @@ -153,24 +171,49 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file_path: FilePath, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None ) -> FlowItem: - if not os.path.isfile(file_path): - error = "File path does not lead to an existing file." - raise IOError(error) if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) - filename = os.path.basename(file_path) - file_extension = os.path.splitext(filename)[1][1:] + if isinstance(file, (str, os.PathLike)): + if not os.path.isfile(file): + error = "File path does not lead to an existing file." + raise IOError(error) + + filename = os.path.basename(file) + file_extension = os.path.splitext(filename)[1][1:] + file_size = os.path.getsize(file) + + # If name is not defined, grab the name from the file to publish + if not flow_item.name: + flow_item.name = os.path.splitext(filename)[0] + if file_extension not in ALLOWED_FILE_EXTENSIONS: + error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) + raise ValueError(error) + + elif isinstance(file, io_types_r): + if not flow_item.name: + error = "Flow item must have a name when passing a file object" + raise ValueError(error) + + file_type = get_file_type(file) + if file_type == "zip": + file_extension = "tflx" + elif file_type == "xml": + file_extension = "tfl" + else: + error = "Unsupported file type {}!".format(file_type) + raise ValueError(error) + + # Generate filename for file object. + # This is needed when publishing the flow in a single request + filename = "{}.{}".format(flow_item.name, file_extension) + file_size = get_file_object_size(file) - # If name is not defined, grab the name from the file to publish - if not flow_item.name: - flow_item.name = os.path.splitext(filename)[0] - if file_extension not in ALLOWED_FILE_EXTENSIONS: - error = "Only {} files can be published as flows.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) - raise ValueError(error) + else: + raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode url = "{0}?flowType={1}".format(self.baseurl, file_extension) @@ -178,15 +221,24 @@ def publish( url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) - if os.path.getsize(file_path) >= FILESIZE_LIMIT: + if file_size >= FILESIZE_LIMIT: logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) - upload_session_id = self.parent_srv.fileuploads.upload(file_path) + upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: logger.info("Publishing {0} to server".format(filename)) - with open(file_path, "rb") as f: - file_contents = f.read() + + if isinstance(file, (str, Path)): + with open(file, "rb") as f: + file_contents = f.read() + + elif isinstance(file, io_types_r): + file_contents = file.read() + + else: + raise TypeError("file should be a filepath or file object.") + xml_request, content_type = RequestFactory.Flow.publish_req(flow_item, filename, file_contents, connections) # Send the publishing request to server diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index f7c2f9f13..e3e9af2a6 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import MissingRequiredFieldError from ...models import TableauItem -from typing import Callable, TYPE_CHECKING, List, Union +from typing import Optional, Callable, TYPE_CHECKING, List, Union logger = logging.getLogger(__name__) @@ -82,7 +82,7 @@ def permission_fetcher(): item._set_permissions(permission_fetcher) logger.info("Populated permissions for item (ID: {0})".format(item.id)) - def _get_permissions(self, item: TableauItem, req_options: "RequestOptions" = None): + def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 21c828989..65a55bcb6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -85,10 +85,10 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: def add_to_schedule( self, schedule_id: str, - workbook: "WorkbookItem" = None, - datasource: "DatasourceItem" = None, - flow: "FlowItem" = None, - task_type: str = None, + workbook: Optional["WorkbookItem"] = None, + datasource: Optional["DatasourceItem"] = None, + flow: Optional["FlowItem"] = None, + task_type: Optional[str] = None, ) -> List[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 28406ab71..3faf4d173 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -21,7 +21,7 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") - def get(self, req_options: RequestOptions = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -47,7 +47,7 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") - def update(self, user_item: UserItem, password: str = None) -> UserItem: + def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -122,7 +122,7 @@ def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: RequestOptions = None) -> None: + def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -133,7 +133,7 @@ def wb_pager(): user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: RequestOptions = None + self, user_item: UserItem, req_options: Optional[RequestOptions] = None ) -> Tuple[List[WorkbookItem], PaginationItem]: url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) @@ -147,7 +147,7 @@ def populate_favorites(self, user_item: UserItem) -> None: # Get groups for user @api(version="3.7") - def populate_groups(self, user_item: UserItem, req_options: RequestOptions = None) -> None: + def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -161,7 +161,7 @@ def groups_for_user_pager(): user_item._set_groups(groups_for_user_pager) def _get_groups_for_user( - self, user_item: UserItem, req_options: RequestOptions = None + self, user_item: UserItem, req_options: Optional[RequestOptions] = None ) -> Tuple[List[GroupItem], PaginationItem]: url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 4d7a4a2b5..8cca4150a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -45,6 +45,9 @@ from ...models.connection_credentials import ConnectionCredentials from .schedules_endpoint import AddResponse +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -53,7 +56,10 @@ logger = logging.getLogger("tableau.endpoint.workbooks") FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] -PathOrFile = Union[FilePath, FileObject] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] class Workbooks(QuerysetEndpoint): @@ -117,12 +123,13 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") - def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True) -> None: + def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) - datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, None) + datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return new_job # Delete 1 workbook by id @api(version="2.0") @@ -178,38 +185,11 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec def download( self, workbook_id: str, - filepath: FilePath = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, ) -> str: - if not workbook_id: - error = "Workbook ID undefined." - raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, workbook_id) - - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract - - if not include_extract: - url += "?includeExtract=False" - - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - logger.info("Downloaded workbook to {0} (ID: {1})".format(download_path, workbook_id)) - return os.path.abspath(download_path) + return self.download_revision(workbook_id, None, filepath, include_extract, no_extract) # Get all views of workbook @api(version="2.0") @@ -250,7 +230,7 @@ def connection_fetcher(): logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections( - self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None + self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None ) -> List[ConnectionItem]: url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) @@ -259,7 +239,7 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: "RequestOptions" = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -331,7 +311,7 @@ def delete_permission(self, item, capability_item): def publish( self, workbook_item: WorkbookItem, - file: PathOrFile, + file: PathOrFileR, mode: str, connection_credentials: Optional["ConnectionCredentials"] = None, connections: Optional[Sequence[ConnectionItem]] = None, @@ -349,7 +329,6 @@ def publish( ) if isinstance(file, (str, os.PathLike)): - # Expect file to be a filepath if not os.path.isfile(file): error = "File path does not lead to an existing file." raise IOError(error) @@ -365,12 +344,12 @@ def publish( error = "Only {} files can be published as workbooks.".format(", ".join(ALLOWED_FILE_EXTENSIONS)) raise ValueError(error) - elif isinstance(file, (io.BytesIO, io.BufferedReader)): - # Expect file to be a file object - file_size = get_file_object_size(file) + elif isinstance(file, io_types_r): + if not workbook_item.name: + error = "Workbook item must have a name when passing a file object" + raise ValueError(error) file_type = get_file_type(file) - if file_type == "zip": file_extension = "twbx" elif file_type == "xml": @@ -379,13 +358,10 @@ def publish( error = "Unsupported file type {}!".format(file_type) raise ValueError(error) - if not workbook_item.name: - error = "Workbook item must have a name when passing a file object" - raise ValueError(error) - # Generate filename for file object. # This is needed when publishing the workbook in a single request filename = "{}.{}".format(workbook_item.name, file_extension) + file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") @@ -427,7 +403,7 @@ def publish( with open(file, "rb") as f: file_contents = f.read() - elif isinstance(file, (io.BytesIO, io.BufferedReader)): + elif isinstance(file, io_types_r): file_contents = file.read() else: @@ -488,14 +464,17 @@ def download_revision( self, workbook_id: str, revision_number: str, - filepath: Optional[PathOrFile] = None, + filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, - ) -> str: + ) -> PathOrFileW: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + if revision_number is None: + url = "{0}/{1}/content".format(self.baseurl, workbook_id) + else: + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) if no_extract is False or no_extract is True: import warnings @@ -511,17 +490,22 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) - filename = to_filename(os.path.basename(params["filename"])) - - download_path = make_download_path(filepath, filename) - - with open(download_path, "wb") as f: + if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) + filepath.write(chunk) + return_path = filepath + else: + filename = to_filename(os.path.basename(params["filename"])) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return_path = os.path.abspath(download_path) + logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, download_path, workbook_id) + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) ) - return os.path.abspath(download_path) + return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index aad8ca074..720eb4085 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -7,6 +7,7 @@ from tableauserverclient.models.metric_item import MetricItem +from ..models import ConnectionCredentials from ..models import ConnectionItem from ..models import DataAlertItem from ..models import FlowItem @@ -55,6 +56,13 @@ def _add_connections_element(connections_element, connection): connection_element.attrib["serverPort"] = connection.server_port if connection.connection_credentials: connection_credentials = connection.connection_credentials + elif connection.username is not None and connection.password is not None and connection.embed_password is not None: + connection_credentials = ConnectionCredentials( + connection.username, connection.password, embed=connection.embed_password + ) + else: + connection_credentials = None + if connection_credentials: _add_credentials_element(connection_element, connection_credentials) @@ -66,7 +74,7 @@ def _add_hiddenview_element(views_element, view_name): def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") - if not connection_credentials.password or not connection_credentials.name: + if connection_credentials.password is None or connection_credentials.name is None: raise ValueError("Connection Credentials must have a name and password") credentials_element.attrib["name"] = connection_credentials.name credentials_element.attrib["password"] = connection_credentials.password @@ -174,10 +182,10 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") - if connection_credentials is not None: + if connection_credentials is not None and connection_credentials != False: _add_credentials_element(datasource_element, connection_credentials) - if connections is not None: + if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(datasource_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) @@ -329,7 +337,7 @@ def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["Conne project_element = ET.SubElement(flow_element, "project") project_element.attrib["id"] = flow_item.project_id - if connections is not None: + if connections is not None and connections != False: connections_element = ET.SubElement(flow_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) @@ -575,7 +583,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo class SiteRequest(object): - def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): + def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -683,7 +691,7 @@ def update_req(self, site_item: "SiteItem", parent_srv: "Server" = None): return ET.tostring(xml_request) # server: the site request model changes based on api version - def create_req(self, site_item: "SiteItem", parent_srv: "Server" = None): + def create_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -896,10 +904,10 @@ def _generate_xml( if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") - if connection_credentials is not None: + if connection_credentials is not None and connection_credentials != False: _add_credentials_element(workbook_element, connection_credentials) - if connections is not None: + if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: _add_connections_element(connections_element, connection) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 5e2dacf33..d2a8b933b 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -31,6 +31,7 @@ Fileuploads, FlowRuns, Metrics, + Endpoint, ) from .endpoint.exceptions import ( ServerInfoEndpointNotFoundError, @@ -62,6 +63,10 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._site_id = None self._user_id = None + # TODO: this needs to change to default to https, but without breaking existing code + if not server_address.startswith("https://") and not server_address.startswith("https://"): + server_address = "https://" + server_address + self._server_address: str = server_address self._session_factory = session_factory or requests.session @@ -96,21 +101,17 @@ def __init__(self, server_address, use_server_version=False, http_options=None, if http_options: self.add_http_options(http_options) - self.validate_server_connection() + self.validate_connection_settings() # does not make an actual outgoing request self.version = default_server_version if use_server_version: self.use_server_version() # this makes a server call - def validate_server_connection(self): + def validate_connection_settings(self): try: - if not self._server_address.startswith("https://") and not self._server_address.startswith("https://"): - self._server_address = "https://" + self._server_address - self._session.prepare_request( - requests.Request("GET", url=self._server_address, params=self._http_options) - ) + Endpoint(self).set_parameters(self._http_options, None, None, None, None) except Exception as req_ex: - raise ValueError("Invalid server initialization", req_ex) + raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) @@ -143,10 +144,12 @@ def _set_auth(self, site_id, user_id, auth_token): self._auth_token = auth_token def _get_legacy_version(self): - response = self._session.get(self.server_address + "/auth?format=xml") + dest = Endpoint(self) + response = dest._make_request(method=self.session.get, url=self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: + logging.getLogger("TSC.server").info(parseError) logging.getLogger("TSC.server").info( "Could not read server version info. The server may not be running or configured." ) diff --git a/test/assets/SampleFlow.tfl b/test/assets/SampleFlow.tfl new file mode 100644 index 0000000000000000000000000000000000000000..c46d9ced964c70d7601e58f4b4d3002412dc4dc1 GIT binary patch literal 1884 zcmWIWW@Zs#;Nak3Q1jLeV?YA@Kz2%IaY0UEWpHXqNoHPpacciYzC#KkuHWleU*2&H z5BI%uWy!Il76O$~XI?L6l**O*7rWrbg&p&XcYZe%>zS%suUX5OyJXR%+#9>PLM}C} zQ>yiFD7?Z{lCaoaa>|j5F6UxQHSbS4az{|y-ty}O-?uC7S!Y^3apt||RW+;T*zLVx zwdDa!K{-3cgiICg;#i=PDt$jX%sx3u&PBtv@OF#H*N*ze;#C|*yJ}3W zGYc(?KR=h!WVsNQct2=Lc$xMc@c?gjj>t!=@9Y42sfm$+ApozZa}!gGON#P+Q%e$4 z5=#=7hD7HJyNlF45AXStvP9tMN$$JuP0dcME;G6XoK~;=enT$ed`^0rFSE<4y<%L` zPq{HMG9A%1@#J20spV8|V7%EMhJTJrIhSRAb1zBLFB94-+BALYw>j@?o?B11oc>*5 z%Ie3Td*(CgaV)k>S$N1JN42EARA*aQOxZ>c)%mtvuM^H}R2Ba%qhup~ewu@q)9NJk zoax4$3Q26!^&ew-JoT==5nisF{n>N- z{py?bUz3V|8lI}(VRpa%N0G#*eZ_nKC**!UUfr3z`1`8*-^=ULb<=hnc%$DR9afTW z_sZ5a+I-r-xZLO4>+;yYIX4?~1;=bWP9XrRQ}QcfEbN zDAl*d&}`rP^~=4(|Fyn-+qJCv`oxEys@T`A|8sM9tJK3!yZa*I6Q89YkkNY)-&=W` zOMHpro4rD>ManL|IMT%Vq(O4NU!dAUE!WC7S2|VroE^5Va#>r*k`cuSb~j5?W( zDgL+EOYV0GRa+=u`7L0VUidOi+C<*>L{I5$wKvZ$1<$crwfx7LyY;n)eLl9g_RoEE zHtkHn!*5f)7D6@u3Bwtc_gn7VY8MrB$&w%2rfoRYIx9j*q{)&;Gu@^4X%SyQ_AbJL5j%?}UpvLaT$+9HwN?@mLu@-M@5u3(x%%-j{SP zue`gdcCXLY57TGeR4dV|J^5?bq+RjzQ%d>0KTYM{z3yE7oLPtd`P-Vd{*Cf^_}|Z0 z(bDvnb^E!uGk>##^5Vl@^Edv?3=Gn2`0^qPFt?`VJ8JJ@x|K!EMRZhyfdvkO-Y zSMF;R|F}i`rnAvZhiPULO`5!4?vBnD7rFWR`}zOIhr0~x3~!c%XnddZ_NKu_^-YD3 z3jbR08%{~Mm0VA>Mqf)+6@bNpME!e zv9i{0@4J#`>QC3d{CxCjuyakY?P~9ew;I!$t0nF?T;jD-U9(9cVfW-iCZ)UZj}eul&{f>DsNU!t&v`*KcgrUhmm1T1j3D1{F>Q`J1N2)F z-jYZR>U;O1#Da{FjMChsyu{2Lz2dTT-@db4%?1K3@u@e1xsR?{)*<$IVZKtX57Wo? z9TLC4`7UXfON(sFZ+p4z?g7J}mBAS^ueS>r9#cA%#OW5|q9W8NaCi1vC(o9It;W+t zr=68eEVr_o?ySh@=*zMxZ^QehI>Qw4hsF2e9skBp2$Y!noojLFnqa>t3HK!*7@WDi zqs8;#&tHvSU4yE%Z@-%5IC;vlukj#nS?9+aOa?mo5D*7=Gct)VAnFz5!WUGppaQg7 z23<39YC+Zf6=)7xX^pNKIj}(q3IQ5{Tu|~t*MaO0P$VEgJu5^9W|RbYvjUSc0|N^X KegM*8U>*P_VF4)s literal 0 HcmV?d00001 diff --git a/test/assets/datasource_revision.xml b/test/assets/datasource_revision.xml index 598c8ad45..8cadafc8f 100644 --- a/test/assets/datasource_revision.xml +++ b/test/assets/datasource_revision.xml @@ -2,13 +2,13 @@ - - + + - + - - + + \ No newline at end of file diff --git a/test/assets/flow_publish.xml b/test/assets/flow_publish.xml new file mode 100644 index 000000000..55af88d11 --- /dev/null +++ b/test/assets/flow_publish.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/workbook_revision.xml b/test/assets/workbook_revision.xml index 598c8ad45..8cadafc8f 100644 --- a/test/assets/workbook_revision.xml +++ b/test/assets/workbook_revision.xml @@ -2,13 +2,13 @@ - - + + - + - - + + \ No newline at end of file diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index e96879277..bf9292dec 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -82,20 +82,20 @@ def test_http_options_not_sequence_fails(self): def test_validate_connection_http(self): url = "http://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(url, server.server_address) def test_validate_connection_https(self): url = "https://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(url, server.server_address) def test_validate_connection_no_protocol(self): url = "cookies.com" fixed_url = "http://cookies.com" server = TSC.Server(url) - server.validate_server_connection() + server.validate_connection_settings() self.assertEqual(fixed_url, server.server_address) diff --git a/test/test_datasource.py b/test/test_datasource.py index 46378201f..e486eec33 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -470,6 +470,18 @@ def test_download(self) -> None: self.assertTrue(os.path.exists(file_path)) os.remove(file_path) + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = self.server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=file_object + ) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) diff --git a/test/test_endpoint.py b/test/test_endpoint.py index e583a9188..5b6324cab 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -38,3 +38,21 @@ class FakeResponse(object): server_response = FakeResponse() log = endpoint.log_response_safely(server_response) self.assertTrue(log.find("[Truncated File Contents]") > 0, log) + + def test_set_user_agent_from_options_headers(self): + params = {"User-Agent": "1", "headers": {"User-Agent": "2"}} + result = TSC.server.Endpoint.set_user_agent(params) + # it should use the value under 'headers' if more than one is given + print(result) + print(result["headers"]["User-Agent"]) + self.assertTrue(result["headers"]["User-Agent"] == "2") + + def test_set_user_agent_from_options(self): + params = {"headers": {"User-Agent": "2"}} + result = TSC.server.Endpoint.set_user_agent(params) + self.assertTrue(result["headers"]["User-Agent"] == "2") + + def test_set_user_agent_when_blank(self): + params = {"headers": {}} + result = TSC.server.Endpoint.set_user_agent(params) + self.assertTrue(result["headers"]["User-Agent"].startswith("Tableau Server Client")) diff --git a/test/test_flow.py b/test/test_flow.py index 269bc2f7e..bbd8a39d3 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,16 +1,21 @@ +import os +import requests_mock import unittest -import requests_mock +from io import BytesIO import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from ._utils import read_xml_asset, asset -GET_XML = "flow_get.xml" -POPULATE_CONNECTIONS_XML = "flow_populate_connections.xml" -POPULATE_PERMISSIONS_XML = "flow_populate_permissions.xml" -UPDATE_XML = "flow_update.xml" -REFRESH_XML = "flow_refresh.xml" +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "flow_get.xml") +POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_connections.xml") +POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_permissions.xml") +PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "flow_publish.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "flow_update.xml") +REFRESH_XML = os.path.join(TEST_ASSET_DIR, "flow_refresh.xml") class FlowTests(unittest.TestCase): @@ -24,6 +29,26 @@ def setUp(self) -> None: self.baseurl = self.server.flows.baseurl + def test_download(self) -> None: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, + ) + file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837") + self.assertTrue(os.path.exists(file_path)) + os.remove(file_path) + + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, + ) + file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=file_object) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: @@ -116,6 +141,52 @@ def test_populate_permissions(self) -> None: }, ) + def test_publish(self) -> None: + with open(PUBLISH_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_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") + publish_mode = self.server.PublishMode.CreateNew + + new_flow = self.server.flows.publish(new_flow, sample_flow, publish_mode) + + self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) + self.assertEqual("SampleFlow", new_flow.name) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) + self.assertEqual("default", new_flow.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) + + def test_publish_file_object(self) -> None: + with open(PUBLISH_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_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") + publish_mode = self.server.PublishMode.CreateNew + + with open(sample_flow, "rb") as fp: + + publish_mode = self.server.PublishMode.CreateNew + + new_flow = self.server.flows.publish(new_flow, fp, publish_mode) + + self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) + self.assertEqual("SampleFlow", new_flow.name) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) + self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) + self.assertEqual("default", new_flow.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) + def test_refresh(self): with open(asset(REFRESH_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_job.py b/test/test_job.py index 19a93e808..83edadaef 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -53,8 +53,10 @@ def test_get_by_id(self) -> None: with requests_mock.mock() as m: m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) + updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) self.assertEqual(job_id, job.id) + self.assertEqual(updated_at, job.updated_at) self.assertListEqual(job.notes, ["Job detail notes"]) def test_get_before_signin(self) -> None: diff --git a/test/test_workbook.py b/test/test_workbook.py index db7f0723b..2e5de9369 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -267,6 +267,16 @@ def test_download(self) -> None: self.assertTrue(os.path.exists(file_path)) os.remove(file_path) + def test_download_object(self) -> None: + with BytesIO() as file_object: + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=file_object) + self.assertTrue(isinstance(file_path, BytesIO)) + def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) @@ -748,6 +758,30 @@ def test_publish_multi_connection(self) -> None: self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] + def test_publish_multi_connection_flat(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.username = "test" + connection1.password = "secret" + connection1.embed_password = True + connection2 = TSC.ConnectionItem() + connection2.server_address = "pgsql.test.com" + connection2.username = "test" + connection2.password = "secret" + connection2.embed_password = True + + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = fromstring(response).findall(".//connection") + + self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") + self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] + self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") + self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] + def test_publish_single_connection(self) -> None: new_workbook = TSC.WorkbookItem( name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" @@ -762,6 +796,33 @@ def test_publish_single_connection(self) -> None: self.assertEqual(credentials[0].get("password", None), "secret") self.assertEqual(credentials[0].get("embed", None), "true") + def test_publish_single_connection_username_none(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + connection_creds = TSC.ConnectionCredentials(None, "secret", True) + + self.assertRaises( + ValueError, + RequestFactory.Workbook._generate_xml, + new_workbook, + connection_credentials=connection_creds, + ) + + def test_publish_single_connection_username_empty(self) -> None: + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) + connection_creds = TSC.ConnectionCredentials("", "secret", True) + + response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = fromstring(response).findall(".//connectionCredentials") + self.assertEqual(len(credentials), 1) + self.assertEqual(credentials[0].get("name", None), "") + self.assertEqual(credentials[0].get("password", None), "secret") + self.assertEqual(credentials[0].get("embed", None), "true") + def test_credentials_and_multi_connect_raises_exception(self) -> None: new_workbook = TSC.WorkbookItem( name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" From ccdd790c592c6e8cba882e26d13dd2083a9f828a Mon Sep 17 00:00:00 2001 From: r-richmond Date: Mon, 20 Feb 2023 16:16:22 -0800 Subject: [PATCH 297/567] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Allow=20more=20rec?= =?UTF-8?q?ent=20version=20of=20packaging=20(#1196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit re-format to match new black standards --- pyproject.toml | 2 +- samples/add_default_permission.py | 1 - samples/create_group.py | 1 - samples/create_schedules.py | 1 - samples/explore_datasource.py | 1 - samples/explore_site.py | 1 - samples/explore_webhooks.py | 3 --- samples/explore_workbook.py | 2 -- samples/extracts.py | 2 -- samples/filter_sort_groups.py | 1 - samples/filter_sort_projects.py | 1 - samples/initialize_server.py | 2 -- samples/login.py | 1 - samples/move_workbook_projects.py | 1 - samples/move_workbook_sites.py | 2 -- samples/pagination_sample.py | 1 - samples/publish_workbook.py | 2 -- samples/query_permissions.py | 1 - tableauserverclient/models/connection_item.py | 1 - tableauserverclient/models/group_item.py | 1 - tableauserverclient/models/interval_item.py | 2 -- tableauserverclient/models/property_decorators.py | 1 - tableauserverclient/models/table_item.py | 1 - tableauserverclient/models/user_item.py | 1 - tableauserverclient/server/endpoint/datasources_endpoint.py | 1 - tableauserverclient/server/endpoint/schedules_endpoint.py | 1 - tableauserverclient/server/endpoint/workbooks_endpoint.py | 1 - tableauserverclient/server/pager.py | 1 - test/test_datasource.py | 1 - test/test_endpoint.py | 1 - test/test_filesys_helpers.py | 6 ------ test/test_flow.py | 1 - test/test_project.py | 1 - test/test_site_model.py | 1 - test/test_user_model.py | 1 - test/test_workbook.py | 5 ----- 36 files changed, 1 insertion(+), 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 840c062e2..c9672462a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', - 'packaging~=21.3', + 'packaging>=22.0', # bumping to minimum version required by black 'requests>=2.28', 'urllib3~=1.26.8', ] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 829190359..8a87c1fd6 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -46,7 +46,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Create a sample project project = TSC.ProjectItem("sample_project") project = server.projects.create(project) diff --git a/samples/create_group.py b/samples/create_group.py index d5cf712db..2229f7f26 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -18,7 +18,6 @@ def main(): - parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/create_schedules.py b/samples/create_schedules.py index 87b43dbca..f193352de 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -15,7 +15,6 @@ def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 014a274ef..aafbe167c 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -16,7 +16,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/explore_site.py b/samples/explore_site.py index 8c4abd9d3..a181abfec 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -12,7 +12,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 764fb0904..47e59ac06 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -49,10 +48,8 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Create webhook if create flag is set (-create, -c) if args.create: - new_webhook = TSC.WebhookItem() new_webhook.name = args.create new_webhook.url = "https://ifttt.com/maker-url" diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index a5a337653..355319971 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -52,7 +51,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Publish workbook if publish flag is set (-publish, -p) overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: diff --git a/samples/extracts.py b/samples/extracts.py index e5879a825..c77da89d0 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -17,7 +17,6 @@ def main(): - parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -50,7 +49,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index c63764134..984d8d344 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -53,7 +53,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" create_example_group(group_name, server) diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index bd43cd209..608f472ba 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -18,7 +18,6 @@ def create_example_project( description="Project created for testing", server=None, ): - new_project = TSC.ProjectItem(name=name, content_permissions=content_permissions, description=description) try: server.projects.create(new_project) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index 21b243013..e7ed0139f 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -45,7 +45,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - ################################################################################ # Step 2: Create the site we need only if it doesn't exist ################################################################################ @@ -75,7 +74,6 @@ def main(): tableau_auth.site_id = args.site_id with server_upload.auth.sign_in(tableau_auth): - ################################################################################ # Step 4: Create the project we need only if it doesn't exist ################################################################################ diff --git a/samples/login.py b/samples/login.py index f0ff9ad49..f3e9d77dc 100644 --- a/samples/login.py +++ b/samples/login.py @@ -48,7 +48,6 @@ def sample_define_common_options(parser): def sample_connect_to_server(args): - if args.username: # Trying to authenticate using username and password. password = args.password or getpass.getpass("Password: ") diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index 884c7eab1..be49ec23b 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -15,7 +15,6 @@ def main(): - parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index a2d11bdfe..3feb62be2 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -16,7 +16,6 @@ def main(): - parser = argparse.ArgumentParser( description="Move one workbook from the" "default project of the default site to" @@ -84,7 +83,6 @@ def main(): # Signing into another site requires another server object # because of the different auth token and site ID. with dest_server.auth.sign_in(tableau_auth): - # Step 5: Create a new workbook item and publish workbook. Note that # an empty project_id will publish to the 'Default' project. new_workbook = TSC.WorkbookItem(name=args.workbook_name, project_id="") diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index e194f59f5..b55fef320 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -18,7 +18,6 @@ def main(): - parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index a0bf1794b..f0edc380c 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -22,7 +22,6 @@ def main(): - parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples parser.add_argument("--server", "-s", required=True, help="server address") @@ -55,7 +54,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Get all the projects on server, then look for the default one. all_projects, pagination_item = server.projects.get() default_project = next((project for project in all_projects if project.is_default()), None) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 0c285d4c3..7106da934 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -44,7 +44,6 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Mapping to grab the handler for the user-inputted resource type endpoint = { "workbook": server.workbooks, diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index ed7733076..a170c5300 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -85,7 +85,6 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) if connection_credentials is not None: - connection_item.connection_credentials = ConnectionCredentials.from_xml_element( connection_credentials, ns ) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index eb03b1b5d..a9cb2dcce 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -12,7 +12,6 @@ class GroupItem(object): - tag_name: str = "group" class LicenseMode: diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index cf5e70353..25b6d09d7 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -27,7 +27,6 @@ class Day: class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): - self.start_time = start_time self.end_time = end_time self.interval = interval_value @@ -70,7 +69,6 @@ def interval(self, interval): self._interval = interval def _interval_type_pairs(self): - # We use fractional hours for the two minute-based intervals. # Need to convert to minutes from hours here if self.interval in {0.25, 0.5}: diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 2d7e01557..af8883290 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -101,7 +101,6 @@ def wrapper(self, value): def property_matches(regex_to_match, error): - compiled_re = re.compile(regex_to_match) def wrapper(func): diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 93edac63c..7fbaa32d2 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -140,7 +140,6 @@ def from_response(cls, resp, ns): @staticmethod def _parse_element(table_xml, ns): - table_values = table_xml.attrib.copy() contact = table_xml.find(".//t:contact", namespaces=ns) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 032841dc7..c19fd4f97 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -21,7 +21,6 @@ class UserItem(object): - tag_name: str = "user" class Roles: diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 9df7edfc8..97c39d1bb 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -199,7 +199,6 @@ def publish( connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: - if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 65a55bcb6..3010eeb3a 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -90,7 +90,6 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> List[AddResponse]: - # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8cca4150a..b7df3fcbb 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -319,7 +319,6 @@ def publish( hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, ): - if connection_credentials is not None: import warnings diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 2de84b4d1..b65d75ae5 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -13,7 +13,6 @@ class Pager(object): """ def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, "get"): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) diff --git a/test/test_datasource.py b/test/test_datasource.py index e486eec33..4f3529762 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -542,7 +542,6 @@ def test_publish_hyper_file_object_raises_exception(self) -> None: ) def test_publish_tde_file_object_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") tds_asset = asset(os.path.join("Data", "Tableau Samples", "World Indicators.tde")) with open(tds_asset, "rb") as file_object: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 5b6324cab..0d8ae84f2 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -29,7 +29,6 @@ def test_get_request_stream(self) -> None: def test_binary_log_truncated(self): class FakeResponse(object): - headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 645c5d372..4c8fb0f9f 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -10,7 +10,6 @@ class FilesysTests(unittest.TestCase): def test_get_file_size_returns_correct_size(self): - target_size = 1000 # bytes with BytesIO() as f: @@ -21,14 +20,12 @@ def test_get_file_size_returns_correct_size(self): self.assertEqual(file_size, target_size) def test_get_file_size_returns_zero_for_empty_file(self): - with BytesIO() as f: file_size = get_file_object_size(f) self.assertEqual(file_size, 0) def test_get_file_size_coincides_with_built_in_method(self): - asset_path = asset("SampleWB.twbx") target_size = os.path.getsize(asset_path) with open(asset_path, "rb") as f: @@ -37,7 +34,6 @@ def test_get_file_size_coincides_with_built_in_method(self): self.assertEqual(file_size, target_size) def test_get_file_type_identifies_a_zip_file(self): - with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: @@ -59,7 +55,6 @@ def test_get_file_type_identifies_twbx_as_zip_file(self): self.assertEqual(file_type, "zip") def test_get_file_type_identifies_xml_file(self): - root = ET.Element("root") child = ET.SubElement(root, "child") child.text = "This is a child element" @@ -95,7 +90,6 @@ def test_get_file_type_identifies_tde_file(self): self.assertEqual(file_type, "tde") def test_get_file_type_handles_unknown_file_type(self): - # Create a dummy png file with BytesIO() as file_object: png_signature = bytes.fromhex("89504E470D0A1A0A") diff --git a/test/test_flow.py b/test/test_flow.py index bbd8a39d3..d10641809 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -174,7 +174,6 @@ def test_publish_file_object(self) -> None: publish_mode = self.server.PublishMode.CreateNew with open(sample_flow, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_flow = self.server.flows.publish(new_flow, fp, publish_mode) diff --git a/test/test_project.py b/test/test_project.py index 48e6005af..3c75a0d3c 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -139,7 +139,6 @@ def test_update_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) def test_create(self) -> None: - with open(CREATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: diff --git a/test/test_site_model.py b/test/test_site_model.py index eb086f5af..f62eb66f0 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -22,7 +22,6 @@ def test_invalid_admin_mode(self): site.admin_mode = "Hello" def test_invalid_content_url(self): - with self.assertRaises(ValueError): site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎") diff --git a/test/test_user_model.py b/test/test_user_model.py index 32d808f52..fcb9b7f90 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -32,7 +32,6 @@ def test_invalid_site_role(self): class UserDataTest(unittest.TestCase): - logger = logging.getLogger("UserDataTest") role_inputs = [ diff --git a/test/test_workbook.py b/test/test_workbook.py index 2e5de9369..8711ba15e 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -525,7 +525,6 @@ def test_publish_a_packaged_file_object(self) -> None: sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) @@ -545,7 +544,6 @@ def test_publish_a_packaged_file_object(self) -> None: self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) def test_publish_non_packeged_file_object(self) -> None: - with open(PUBLISH_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -558,7 +556,6 @@ def test_publish_non_packeged_file_object(self) -> None: sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) @@ -715,7 +712,6 @@ def test_publish_unnamed_file_object(self) -> None: new_workbook = TSC.WorkbookItem("test") with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: - self.assertRaises( ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew ) @@ -724,7 +720,6 @@ def test_publish_non_bytes_file_object(self) -> None: new_workbook = TSC.WorkbookItem("test") with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: - self.assertRaises( TypeError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew ) From e4fbe41560fbd2314c9c7b8b8a169164dd15185f Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 16 Mar 2023 11:05:21 -0700 Subject: [PATCH 298/567] Push code for 0.25 with custom views (#1206) * Implement custom view objects (#1195) * Fix bug in update-datasources before 3.15 (#1203) (fixes #1072) * catch exceptions from ServerInfo (#1204) * add query-tagging attribute to connection (#1202) (add explanation for why it doesn't work on hyper) --------- Co-authored-by: Marwan Baghdad Co-authored-by: jorwoods Co-authored-by: Brian Cantoni Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Co-authored-by: Stu Tomlinson Co-authored-by: Jeremy Harris --- .github/workflows/code-coverage.yml | 2 +- pyproject.toml | 2 +- samples/explore_workbook.py | 29 ++++ tableauserverclient/__init__.py | 39 +---- tableauserverclient/models/__init__.py | 3 + tableauserverclient/models/connection_item.py | 30 +++- .../models/custom_view_item.py | 156 ++++++++++++++++++ tableauserverclient/models/data_alert_item.py | 20 +-- tableauserverclient/models/datasource_item.py | 44 +++-- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/flow_item.py | 38 ++--- tableauserverclient/models/flow_run_item.py | 21 +-- tableauserverclient/models/group_item.py | 2 +- tableauserverclient/models/job_item.py | 36 ++-- tableauserverclient/models/metric_item.py | 17 +- .../models/permissions_item.py | 12 +- tableauserverclient/models/project_item.py | 8 +- .../models/property_decorators.py | 2 +- tableauserverclient/models/revision_item.py | 12 +- tableauserverclient/models/schedule_item.py | 2 +- .../models/server_info_item.py | 10 +- tableauserverclient/models/site_item.py | 1 + tableauserverclient/models/tableau_auth.py | 2 + tableauserverclient/models/tableau_types.py | 14 +- tableauserverclient/models/tag_item.py | 3 +- tableauserverclient/models/task_item.py | 2 +- tableauserverclient/models/user_item.py | 25 ++- tableauserverclient/models/view_item.py | 29 ++-- tableauserverclient/models/workbook_item.py | 38 ++--- tableauserverclient/server/__init__.py | 52 +----- .../server/endpoint/__init__.py | 1 + .../server/endpoint/custom_views_endpoint.py | 104 ++++++++++++ .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 3 +- .../server/endpoint/databases_endpoint.py | 3 +- .../server/endpoint/datasources_endpoint.py | 53 +++--- .../endpoint/default_permissions_endpoint.py | 4 +- .../server/endpoint/dqw_endpoint.py | 3 +- .../server/endpoint/endpoint.py | 14 +- .../server/endpoint/favorites_endpoint.py | 8 +- .../server/endpoint/fileuploads_endpoint.py | 4 +- .../server/endpoint/flow_runs_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 11 +- .../server/endpoint/groups_endpoint.py | 3 +- .../server/endpoint/jobs_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 5 +- .../server/endpoint/permissions_endpoint.py | 6 +- .../server/endpoint/projects_endpoint.py | 3 +- .../server/endpoint/resource_tagger.py | 4 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 8 +- .../server/endpoint/sites_endpoint.py | 3 +- .../server/endpoint/subscriptions_endpoint.py | 3 +- .../server/endpoint/tables_endpoint.py | 3 +- .../server/endpoint/tasks_endpoint.py | 3 +- .../server/endpoint/users_endpoint.py | 9 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 4 +- .../server/endpoint/workbooks_endpoint.py | 25 +-- tableauserverclient/server/request_factory.py | 32 ++-- tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 49 +++--- test/assets/custom_view_get.xml | 16 ++ test/assets/custom_view_get_id.xml | 8 + test/assets/custom_view_update.xml | 8 + test/assets/server_info_get.xml | 4 +- test/http/test_http_requests.py | 8 +- test/models/_models.py | 61 +++++++ test/models/test_repr.py | 40 +++++ test/test_connection_.py | 34 ++++ test/test_custom_view.py | 133 +++++++++++++++ test/test_datasource_model.py | 11 +- test/test_server_info.py | 11 +- test/test_user_model.py | 10 -- test/test_workbook.py | 7 +- 75 files changed, 963 insertions(+), 426 deletions(-) create mode 100644 tableauserverclient/models/custom_view_item.py create mode 100644 tableauserverclient/server/endpoint/custom_views_endpoint.py create mode 100644 test/assets/custom_view_get.xml create mode 100644 test/assets/custom_view_get_id.xml create mode 100644 test/assets/custom_view_update.xml create mode 100644 test/models/_models.py create mode 100644 test/models/test_repr.py create mode 100644 test/test_connection_.py create mode 100644 test/test_custom_view.py diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d393a06d5..6d74c5c38 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest] python-version: ['3.10'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index c9672462a..ee793ec41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 355319971..f242ace70 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -72,6 +72,10 @@ def main(): if all_workbooks: # Pick one workbook from the list sample_workbook = all_workbooks[0] + sample_workbook.name = "Name me something cooler" + sample_workbook.description = "That doesn't work" + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print(updated.name, updated.description) # Populate views server.workbooks.populate_views(sample_workbook) @@ -125,6 +129,31 @@ def main(): f.write(sample_workbook.preview_image) print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + # get custom views + cvs, _ = server.custom_views.get() + for c in cvs: + print(c) + + # for the last custom view in the list + + # update the name + # note that this will fail if the name is already changed to this value + changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc") + verified_change = server.custom_views.update(changed) + print(verified_change) + + # export as image. Filters etc could be added here as usual + server.custom_views.populate_image(c) + filename = c.id + "-image-export.png" + with open(filename, "wb") as f: + f.write(c.image) + print("saved to " + filename) + + if args.delete: + print("deleting {}".format(c.id)) + unlucky = TSC.CustomViewItem(c.id) + server.custom_views.delete(unlucky.id) + if __name__ == "__main__": main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 212540d84..03e484372 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,43 +1,6 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ( - BackgroundJobItem, - ColumnItem, - ConnectionCredentials, - ConnectionItem, - DQWItem, - DailyInterval, - DataAlertItem, - DatabaseItem, - DatasourceItem, - FlowItem, - FlowRunItem, - GroupItem, - HourlyInterval, - IntervalItem, - JobItem, - MetricItem, - MonthlyInterval, - PaginationItem, - Permission, - PermissionsRule, - PersonalAccessTokenAuth, - ProjectItem, - RevisionItem, - ScheduleItem, - SiteItem, - SubscriptionItem, - TableItem, - TableauAuth, - Target, - TaskItem, - UnpopulatedPropertyError, - UserItem, - ViewItem, - WebhookItem, - WeeklyInterval, - WorkbookItem, -) +from .models import * from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 58e5ed6d1..b4a52f753 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,6 +1,7 @@ from .column_item import ColumnItem from .connection_credentials import ConnectionCredentials from .connection_item import ConnectionItem +from .custom_view_item import CustomViewItem from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem from .database_item import DatabaseItem @@ -8,6 +9,7 @@ from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError from .favorites_item import FavoriteItem +from .fileupload_item import FileuploadItem from .flow_item import FlowItem from .flow_run_item import FlowRunItem from .group_item import GroupItem @@ -31,6 +33,7 @@ from .table_item import TableItem from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth from .tableau_types import Resource, TableauItem, plural_type +from .tag_item import TagItem from .target import Target from .task_item import TaskItem from .user_item import UserItem diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index a170c5300..4ed06b831 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING, List, Optional +import logging +from typing import List, Optional + from defusedxml.ElementTree import fromstring from .connection_credentials import ConnectionCredentials - -if TYPE_CHECKING: - from tableauserverclient.models.connection_credentials import ConnectionCredentials +from .property_decorators import property_is_boolean class ConnectionItem(object): @@ -18,7 +18,8 @@ def __init__(self): self.server_address: Optional[str] = None self.server_port: Optional[str] = None self.username: Optional[str] = None - self.connection_credentials: Optional["ConnectionCredentials"] = None + self.connection_credentials: Optional[ConnectionCredentials] = None + self._query_tagging: Optional[bool] = None @property def datasource_id(self) -> Optional[str]: @@ -36,6 +37,22 @@ def id(self) -> Optional[str]: def connection_type(self) -> Optional[str]: return self._connection_type + @property + def query_tagging(self) -> Optional[bool]: + return self._query_tagging + + @query_tagging.setter + @property_is_boolean + def query_tagging(self, value: Optional[bool]): + # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true + if self._connection_type in ["hyper", "snowflake", "teradata"]: + logger = logging.getLogger("tableauserverclient.models.connection_item") + logger.debug( + "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + ) + return + self._query_tagging = value + def __repr__(self): return "".format( **self.__dict__ @@ -54,6 +71,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) connection_item.username = connection_xml.get("userName", None) + connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None)) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) @@ -94,4 +112,4 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: - return s.lower() == "true" + return s is not None and s.lower() == "true" diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py new file mode 100644 index 000000000..e0b47c738 --- /dev/null +++ b/tableauserverclient/models/custom_view_item.py @@ -0,0 +1,156 @@ +from datetime import datetime + +from defusedxml import ElementTree +from defusedxml.ElementTree import fromstring, tostring +from typing import Callable, List, Optional + +from .exceptions import UnpopulatedPropertyError +from .user_item import UserItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem +from ..datetime_helpers import parse_datetime + + +class CustomViewItem(object): + def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: + self._content_url: Optional[str] = None # ? + self._created_at: Optional["datetime"] = None + self._id: Optional[str] = id + self._image: Optional[Callable[[], bytes]] = None + self._name: Optional[str] = name + self._shared: Optional[bool] = False + self._updated_at: Optional["datetime"] = None + + self._owner: Optional[UserItem] = None + self._view: Optional[ViewItem] = None + self._workbook: Optional[WorkbookItem] = None + + def __repr__(self: "CustomViewItem"): + view_info = "" + if self._view: + view_info = " view='{}'".format(self._view.name or self._view.id or "unknown") + wb_info = "" + if self._workbook: + wb_info = " workbook='{}'".format(self._workbook.name or self._workbook.id or "unknown") + owner_info = "" + if self._owner: + owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") + return "".format(self.id, self.name, view_info, wb_info, owner_info) + + def _set_image(self, image): + self._image = image + + @property + def content_url(self) -> Optional[str]: + return self._content_url + + @property + def created_at(self) -> Optional["datetime"]: + return self._created_at + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def image(self) -> bytes: + if self._image is None: + error = "View item must be populated with its png image first." + raise UnpopulatedPropertyError(error) + return self._image() + + @property + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + @property + def shared(self) -> Optional[bool]: + return self._shared + + @shared.setter + def shared(self, value: bool): + self._shared = value + + @property + def updated_at(self) -> Optional["datetime"]: + return self._updated_at + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @owner.setter + def owner(self, value: UserItem): + self._owner = value + + @property + def workbook(self) -> Optional[WorkbookItem]: + return self._workbook + + @property + def view(self) -> Optional[ViewItem]: + return self._view + + @classmethod + def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: + item = cls.list_from_response(resp, ns, workbook_id) + if not item or len(item) == 0: + return None + else: + return item[0] + + @classmethod + def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + return cls.from_xml_element(fromstring(resp), ns, workbook_id) + + """ + + + + + + """ + + @classmethod + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + all_view_items = list() + all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) + for custom_view_xml in all_view_xml: + cv_item = cls() + view_elem: ElementTree = custom_view_xml.find(".//t:view", namespaces=ns) + workbook_elem: str = custom_view_xml.find(".//t:workbook", namespaces=ns) + owner_elem: str = custom_view_xml.find(".//t:owner", namespaces=ns) + cv_item._created_at = parse_datetime(custom_view_xml.get("createdAt", None)) + cv_item._updated_at = parse_datetime(custom_view_xml.get("updatedAt", None)) + cv_item._content_url = custom_view_xml.get("contentUrl", None) + cv_item._id = custom_view_xml.get("id", None) + cv_item._name = custom_view_xml.get("name", None) + + if owner_elem is not None: + parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) + if parsed_owners and len(parsed_owners) > 0: + cv_item._owner = parsed_owners[0] + + if view_elem is not None: + parsed_views = ViewItem.from_response(tostring(custom_view_xml), ns) + if parsed_views and len(parsed_views) > 0: + cv_item._view = parsed_views[0] + + if workbook_id: + cv_item._workbook = WorkbookItem(workbook_id) + elif workbook_elem is not None: + parsed_workbooks = WorkbookItem.from_response(tostring(custom_view_xml), ns) + if parsed_workbooks and len(parsed_workbooks) > 0: + cv_item._workbook = parsed_workbooks[0] + + all_view_items.append(cv_item) + return all_view_items diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 3882d14eb..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,4 +1,5 @@ -from typing import List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,15 +9,6 @@ property_is_boolean, ) -if TYPE_CHECKING: - from datetime import datetime - - -from typing import List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from datetime import datetime - class DataAlertItem(object): class Frequency: @@ -30,8 +22,8 @@ def __init__(self): self._id: Optional[str] = None self._subject: Optional[str] = None self._creatorId: Optional[str] = None - self._createdAt: Optional["datetime"] = None - self._updatedAt: Optional["datetime"] = None + self._createdAt: Optional[datetime] = None + self._updatedAt: Optional[datetime] = None self._frequency: Optional[str] = None self._public: Optional[bool] = None self._owner_id: Optional[str] = None @@ -90,11 +82,11 @@ def recipients(self) -> List[str]: return self._recipients or list() @property - def createdAt(self) -> Optional["datetime"]: + def createdAt(self) -> Optional[datetime]: return self._createdAt @property - def updatedAt(self) -> Optional["datetime"]: + def updatedAt(self) -> Optional[datetime]: return self._updatedAt @property diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 4a7a74c4b..b5568a778 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,31 +1,21 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING +from typing import Dict, List, Optional, Set, Tuple from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .property_decorators import ( property_not_nullable, property_is_boolean, property_is_enum, ) +from .revision_item import RevisionItem from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime - -from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .permissions_item import PermissionsRule - from .connection_item import ConnectionItem - from .revision_item import RevisionItem - import datetime class DatasourceItem(object): @@ -34,6 +24,14 @@ class AskDataEnablement: Disabled = "Disabled" SiteDefault = "SiteDefault" + def __repr__(self): + return "".format( + self._id, + self.name, + self.description or "No Description", + self.project_id, + ) + def __init__(self, project_id: str, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None @@ -64,23 +62,23 @@ def __init__(self, project_id: str, name: Optional[str] = None) -> None: return None @property - def ask_data_enablement(self) -> Optional["DatasourceItem.AskDataEnablement"]: + def ask_data_enablement(self) -> Optional[AskDataEnablement]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional["DatasourceItem.AskDataEnablement"]): + def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List["ConnectionItem"]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List["PermissionsRule"]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -91,7 +89,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -162,7 +160,7 @@ def description(self, value: str): self._description = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -179,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index 2baecee09..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime class DQWItem(object): diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 18f0ecae2..f48910602 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,43 +1,41 @@ import copy +import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, TYPE_CHECKING, Set +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem +from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError +from .permissions_item import Permission from .property_decorators import property_not_nullable from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime - -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - import datetime - from .connection_item import ConnectionItem - from .permissions_item import Permission - from .dqw_item import DQWItem class FlowItem(object): + def __repr__(self): + return " None: self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime.datetime"] = None + self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None - self._updated_at: Optional["datetime.datetime"] = None + self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id self.tags: Set[str] = set() self.description: Optional[str] = None - self._connections = None - self._permissions = None - self._data_quality_warnings = None + self._connections: Optional[ConnectionItem] = None + self._permissions: Optional[Permission] = None + self._data_quality_warnings: Optional[DQWItem] = None @property def connections(self): @@ -58,7 +56,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -94,7 +92,7 @@ def project_name(self) -> Optional[str]: return self._project_name @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def _set_connections(self, connections): diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index ce859a65b..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,17 +1,10 @@ import itertools -from typing import Dict, List, Optional, Type, TYPE_CHECKING +from datetime import datetime +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - -from typing import Dict, List, Optional, Type, TYPE_CHECKING - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class FlowRunItem(object): @@ -19,8 +12,8 @@ def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None self._status: Optional[str] = None - self._started_at: Optional["datetime"] = None - self._completed_at: Optional["datetime"] = None + self._started_at: Optional[datetime] = None + self._completed_at: Optional[datetime] = None self._progress: Optional[str] = None self._background_job_id: Optional[str] = None @@ -37,11 +30,11 @@ def status(self) -> Optional[str]: return self._status @property - def started_at(self) -> Optional["datetime"]: + def started_at(self) -> Optional[datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime"]: + def completed_at(self) -> Optional[datetime]: return self._completed_at @property diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index a9cb2dcce..96c3ae675 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -8,7 +8,7 @@ from .user_item import UserItem if TYPE_CHECKING: - from ..server import Pager + from tableauserverclient.server import Pager class GroupItem(object): diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index a7490e705..5a2636246 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,12 +1,10 @@ -from typing import List, Optional, TYPE_CHECKING +import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .flow_run_item import FlowRunItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - import datetime class JobItem(object): @@ -25,16 +23,16 @@ def __init__( id_: str, job_type: str, progress: str, - created_at: "datetime.datetime", - started_at: Optional["datetime.datetime"] = None, - completed_at: Optional["datetime.datetime"] = None, + created_at: datetime.datetime, + started_at: Optional[datetime.datetime] = None, + completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, - updated_at: Optional["datetime.datetime"] = None, + updated_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -63,15 +61,15 @@ def progress(self) -> str: return self._progress @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def completed_at(self) -> Optional["datetime.datetime"]: + def completed_at(self) -> Optional[datetime.datetime]: return self._completed_at @property @@ -116,7 +114,7 @@ def flow_run(self, value): self._flow_run = value @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at def __repr__(self): @@ -185,14 +183,14 @@ class Status: def __init__( self, id_: str, - created_at: "datetime.datetime", + created_at: datetime.datetime, priority: int, job_type: str, status: str, title: Optional[str] = None, subtitle: Optional[str] = None, - started_at: Optional["datetime.datetime"] = None, - ended_at: Optional["datetime.datetime"] = None, + started_at: Optional[datetime.datetime] = None, + ended_at: Optional[datetime.datetime] = None, ): self._id = id_ self._type = job_type @@ -223,15 +221,15 @@ def type(self) -> str: return self._type @property - def created_at(self) -> "datetime.datetime": + def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional["datetime.datetime"]: + def started_at(self) -> Optional[datetime.datetime]: return self._started_at @property - def ended_at(self) -> Optional["datetime.datetime"]: + def ended_at(self) -> Optional[datetime.datetime]: return self._ended_at @property diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index a54d1e30e..4adc73fa8 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,11 +1,10 @@ import xml.etree.ElementTree as ET -from ..datetime_helpers import parse_datetime +from datetime import datetime +from typing import List, Optional, Set + +from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime from .tag_item import TagItem -from typing import List, Optional, TYPE_CHECKING, Set - -if TYPE_CHECKING: - from datetime import datetime class MetricItem(object): @@ -14,8 +13,8 @@ def __init__(self, name: Optional[str] = None): self._name: Optional[str] = name self._description: Optional[str] = None self._webpage_url: Optional[str] = None - self._created_at: Optional["datetime"] = None - self._updated_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None + self._updated_at: Optional[datetime] = None self._suspended: Optional[bool] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None @@ -53,7 +52,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @created_at.setter @@ -62,7 +61,7 @@ def created_at(self, value: "datetime") -> None: self._created_at = value @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @updated_at.setter diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 74b167e9d..3bdc63092 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,18 +1,16 @@ import logging import xml.etree.ElementTree as ET +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring + from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError from .group_item import GroupItem +from .reference_item import ResourceReference from .user_item import UserItem logger = logging.getLogger("tableau.models.permissions_item") -from typing import Dict, List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from .reference_item import ResourceReference - class Permission: class Mode: @@ -43,7 +41,7 @@ class Capability: class PermissionsRule(object): - def __init__(self, grantee: "ResourceReference", capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -80,7 +78,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> "ResourceReference": + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index a8430bfd0..21358431c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,13 +1,12 @@ import logging import xml.etree.ElementTree as ET +from typing import List, Optional from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_is_enum, property_not_empty -from typing import List, Optional - class ProjectItem(object): class ContentPermissions: @@ -15,6 +14,11 @@ class ContentPermissions: ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" + def __repr__(self): + return "".format( + self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" + ) + def __init__( self, name: str, diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index af8883290..7c801a4b5 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,7 +2,7 @@ import re from functools import wraps -from ..datetime_helpers import parse_datetime +from tableauserverclient.datetime_helpers import parse_datetime def property_is_enum(enum_type): diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 600d73168..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,11 +1,9 @@ -from typing import List, Optional, TYPE_CHECKING +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime +from tableauserverclient.datetime_helpers import parse_datetime class RevisionItem(object): @@ -15,7 +13,7 @@ def __init__(self): self._revision_number: Optional[str] = None self._current: Optional[bool] = None self._deleted: Optional[bool] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._user_id: Optional[str] = None self._user_name: Optional[str] = None @@ -40,7 +38,7 @@ def deleted(self) -> Optional[bool]: return self._deleted @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 828034d23..54e4badbe 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -4,6 +4,7 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .interval_item import ( IntervalItem, HourlyInterval, @@ -16,7 +17,6 @@ property_not_nullable, property_is_int, ) -from ..datetime_helpers import parse_datetime Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 350ae3a0d..5f9395880 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -1,3 +1,4 @@ +import logging import warnings import xml @@ -35,11 +36,18 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): + logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - warnings.warn("Unexpected response for ServerInfo: {}".format(resp)) + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") + except Exception as error: + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") + product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e6bc3af24..813e812af 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -2,6 +2,7 @@ import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring + from .property_decorators import ( property_is_enum, property_is_boolean, diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 24ba1d682..db21e4aa2 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -53,6 +53,8 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): def __init__(self, token_name, personal_access_token, site_id=None): + if personal_access_token is None or token_name is None: + raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id) self.token_name = token_name self.personal_access_token = personal_access_token diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 6ed77318f..9649c7ed9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,13 +1,11 @@ -from tableauserverclient.models.database_item import DatabaseItem -from tableauserverclient.models.datasource_item import DatasourceItem -from tableauserverclient.models.flow_item import FlowItem -from tableauserverclient.models.project_item import ProjectItem -from tableauserverclient.models.table_item import TableItem -from tableauserverclient.models.view_item import ViewItem -from tableauserverclient.models.workbook_item import WorkbookItem - from typing import Union +from .datasource_item import DatasourceItem +from .flow_item import FlowItem +from .project_item import ProjectItem +from .view_item import ViewItem +from .workbook_item import WorkbookItem + class Resource: Database = "database" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index f7568ae45..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,5 +1,6 @@ -from typing import Set import xml.etree.ElementTree as ET +from typing import Set + from defusedxml.ElementTree import fromstring diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 32299a853..159869b07 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .schedule_item import ScheduleItem from .target import Target -from ..datetime_helpers import parse_datetime class TaskItem(object): diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c19fd4f97..5e3d18fa6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,23 +1,21 @@ import io -import logging import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, property_not_empty, ) from .reference_item import ResourceReference -from ..datetime_helpers import parse_datetime - -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: - from ..server.pager import Pager + from tableauserverclient.server import Pager class UserItem(object): @@ -93,6 +91,10 @@ def external_auth_user_id(self) -> Optional[str]: def id(self) -> Optional[str]: return self._id + @id.setter + def id(self, value: str) -> None: + self._id = value + @property def last_login(self) -> Optional[datetime]: return self._last_login @@ -102,7 +104,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str): self._name = value @@ -206,9 +207,19 @@ def _set_values( @classmethod def from_response(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:user" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + element_name = ".//t:owner" + return cls._parse_xml(element_name, resp, ns) + + @classmethod + def _parse_xml(cls, element_name, resp, ns): all_user_items = [] parsed_response = fromstring(resp) - all_user_xml = parsed_response.findall(".//t:user", namespaces=ns) + all_user_xml = parsed_response.findall(element_name, namespaces=ns) for user_xml in all_user_xml: ( id, diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 01635349b..51cceaa9f 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,21 +1,19 @@ import copy -from typing import Callable, Generator, Iterator, List, Optional, Set, TYPE_CHECKING +from datetime import datetime +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from .exceptions import UnpopulatedPropertyError +from .permissions_item import PermissionsRule from .tag_item import TagItem -from ..datetime_helpers import parse_datetime - -if TYPE_CHECKING: - from datetime import datetime - from .permissions_item import PermissionsRule class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None - self._created_at: Optional["datetime"] = None + self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None self._initial_tags: Set[str] = set() @@ -28,11 +26,16 @@ def __init__(self) -> None: self._excel: Optional[Callable[[], Iterator[bytes]]] = None self._total_views: Optional[int] = None self._sheet_type: Optional[str] = None - self._updated_at: Optional["datetime"] = None + self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List["PermissionsRule"]]] = None + self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + def _set_preview_image(self, preview_image): self._preview_image = preview_image @@ -53,7 +56,7 @@ def content_url(self) -> Optional[str]: return self._content_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> Optional[datetime]: return self._created_at @property @@ -119,7 +122,7 @@ def total_views(self): return self._total_views @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> Optional[datetime]: return self._updated_at @property @@ -127,13 +130,13 @@ def workbook_id(self) -> Optional[str]: return self._workbook_id @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List["PermissionsRule"]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 6d9a21b6b..debbf30b5 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -1,35 +1,22 @@ import copy +import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set, TYPE_CHECKING +from typing import Callable, Dict, List, Optional, Set from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime +from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule from .property_decorators import ( - property_not_nullable, property_is_boolean, property_is_data_acceleration_config, ) +from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem -from ..datetime_helpers import parse_datetime - - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem - -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union - -if TYPE_CHECKING: - from .connection_item import ConnectionItem - from .permissions_item import PermissionsRule - import datetime - from .revision_item import RevisionItem class WorkbookItem(object): @@ -65,15 +52,20 @@ def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool return None + def __repr__(self): + return "".format( + self._id, self.name, self.content_url, self.project_id + ) + @property - def connections(self) -> List["ConnectionItem"]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List["PermissionsRule"]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -88,7 +80,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def created_at(self) -> Optional["datetime.datetime"]: + def created_at(self) -> Optional[datetime.datetime]: return self._created_at @property @@ -146,7 +138,7 @@ def size(self): return self._size @property - def updated_at(self) -> Optional["datetime.datetime"]: + def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property @@ -176,7 +168,7 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def revisions(self) -> List["RevisionItem"]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 84d118a2e..bcea2604e 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,56 +10,8 @@ from .filter import Filter from .sort import Sort -from ..models import ( - BackgroundJobItem, - ColumnItem, - ConnectionItem, - DQWItem, - DataAlertItem, - DatabaseItem, - DatasourceItem, - FlowItem, - FlowRunItem, - GroupItem, - JobItem, - PaginationItem, - Permission, - PermissionsRule, - ProjectItem, - RevisionItem, - ScheduleItem, - SiteItem, - SubscriptionItem, - TableItem, - TableauAuth, - TaskItem, - UserItem, - ViewItem, - WebhookItem, - WorkbookItem, - TableauItem, - Resource, - plural_type, -) -from .endpoint import ( - Auth, - DataAlerts, - Datasources, - Endpoint, - Groups, - Projects, - Schedules, - Sites, - Tables, - Users, - Views, - Workbooks, - Subscriptions, - ServerResponseError, - MissingRequiredFieldError, - Flows, - Favorites, -) +from ..models import * +from .endpoint import * from .server import Server from .pager import Pager from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e14bb8cff..e8e1bc0f9 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,4 +1,5 @@ from .auth_endpoint import Auth +from .custom_views_endpoint import CustomViews from .data_acceleration_report_endpoint import DataAccelerationReport from .data_alert_endpoint import DataAlerts from .databases_endpoint import Databases diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py new file mode 100644 index 000000000..778cafecc --- /dev/null +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -0,0 +1,104 @@ +import logging +from typing import List, Optional, Tuple + +from .endpoint import QuerysetEndpoint, api +from .exceptions import MissingRequiredFieldError +from tableauserverclient.models import CustomViewItem, PaginationItem +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions + +logger = logging.getLogger("tableau.endpoint.custom_views") + +""" +Get a list of custom views on a site +get the details of a custom view +download an image of a custom view. +Delete a custom view +update the name or owner of a custom view. +""" + + +class CustomViews(QuerysetEndpoint): + def __init__(self, parent_srv): + super(CustomViews, self).__init__(parent_srv) + + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + """ + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + """ + + @api(version="3.18") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + logger.info("Querying all custom views on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_view_items = CustomViewItem.list_from_response(server_response.content, self.parent_srv.namespace) + return all_view_items, pagination_item + + @api(version="3.18") + def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + if not view_id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) + server_response = self.get_request(url) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.18") + def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + if not view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def image_fetcher(): + return self._get_view_image(view_item, req_options) + + view_item._set_image(image_fetcher) + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + + def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: + url = "{0}/{1}/image".format(self.baseurl, view_item.id) + server_response = self.get_request(url, req_options) + image = server_response.content + return image + + """ + Not yet implemented: pdf or csv exports + """ + + @api(version="3.18") + def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + if not view_item.id: + error = "Custom view item missing ID." + raise MissingRequiredFieldError(error) + if not (view_item.owner or view_item.name or view_item.shared): + logger.debug("No changes to make") + return view_item + + # Update the custom view owner or name + url = "{0}/{1}".format(self.baseurl, view_item.id) + update_req = RequestFactory.CustomView.update_req(view_item) + server_response = self.put_request(url, update_req) + logger.info("Updated custom view (ID: {0})".format(view_item.id)) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + # Delete 1 view by id + @api(version="3.19") + def delete(self, view_id: str) -> None: + if not view_id: + error = "Custom View ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, view_id) + self.delete_request(url) + logger.info("Deleted single custom view (ID: {0})".format(view_id)) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index f972c0d60..28e5495c5 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -3,7 +3,7 @@ from .default_permissions_endpoint import _DefaultPermissionsEndpoint from .endpoint import api, Endpoint from .permissions_endpoint import _PermissionsEndpoint -from ...models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models import DataAccelerationReportItem logger = logging.getLogger("tableau.endpoint.data_acceleration_report") diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 8929f8c6a..5af4e0464 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DataAlertItem, PaginationItem, UserItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem logger = logging.getLogger("tableau.endpoint.dataAlerts") diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index aa9d73f18..2522ef53e 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -5,7 +5,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, DatabaseItem, TableItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource logger = logging.getLogger("tableau.endpoint.databases") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 97c39d1bb..0c5b8ba61 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,40 +1,49 @@ import cgi import copy -import io import json import logging +import io import os + from contextlib import closing from pathlib import Path -from typing import ( - List, - Mapping, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) +from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union + +if TYPE_CHECKING: + from tableauserverclient.server import Server + from tableauserverclient.models import PermissionsRule + from .schedules_endpoint import AddResponse from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, DatasourceItem, PaginationItem, ConnectionItem, RequestOptions -from ..query import QuerySet -from ...filesys_helpers import ( + +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models import ConnectionCredentials, RevisionItem -from ...models.job_item import JobItem +from tableauserverclient.models import ( + ConnectionCredentials, + ConnectionItem, + DatasourceItem, + JobItem, + RevisionItem, + PaginationItem, +) +io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +PathOrFile = Union[FilePath, FileObject] + # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -42,11 +51,6 @@ logger = logging.getLogger("tableau.endpoint.datasources") -if TYPE_CHECKING: - from ..server import Server - from ...models import PermissionsRule - from .schedules_endpoint import AddResponse - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -136,11 +140,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) + # bug - before v3.15 you must always include the project id + if datasource_item.owner_id and not datasource_item.project_id: + if not self.parent_srv.check_at_least_version("3.15"): + error = ( + "Attempting to set new owner but datasource is missing Project ID." + "In versions before 3.15 the project id must be included to update the owner." + ) + raise MissingRequiredFieldError(error) self._resource_tagger.update_tags(self.baseurl, datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) + update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 66fc23d49..b0d16efaf 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -2,8 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory -from ...models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index ff1637721..96cb7c5f9 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, DQWItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import DQWItem logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index b1a42b20c..9c933c9dd 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,7 +3,7 @@ from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Mapping +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING from .exceptions import ( ServerResponseError, @@ -11,8 +11,8 @@ NonXMLResponseError, EndpointUnavailableError, ) -from ..query import QuerySet -from ... import helpers, get_versions +from tableauserverclient.server.query import QuerySet +from tableauserverclient import helpers, get_versions if TYPE_CHECKING: from ..server import Server @@ -78,16 +78,16 @@ def _make_request( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request {}, url: {}".format(method, url)) + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:1000]) - logger.debug("request content: {}".format(redacted)) + # logger.debug("request content: {}".format(redacted)) server_response = method(url, **parameters) self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) @@ -258,7 +258,7 @@ def all(self, *args, **kwargs): return queryset @api(version="2.0") - def filter(self, *_, **kwargs): + def filter(self, *_, **kwargs) -> QuerySet: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 19199c5a0..5105b3bf4 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,10 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import FavoriteItem - -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem from typing import Optional, TYPE_CHECKING @@ -12,6 +10,8 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions +logger = logging.getLogger("tableau.endpoint.favorites") + class Favorites(Endpoint): @property diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 3df8ee4d5..9a8e9560d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models.fileupload_item import FileuploadItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FileuploadItem # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE = 1024 * 1024 * 5 # 5MB diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 62f910dea..3bca93a7f 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -3,8 +3,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import FlowRunFailedException, FlowRunCancelledException -from .. import FlowRunItem, PaginationItem -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.flowruns") diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 5b182111b..4d97110c4 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -8,18 +8,21 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import Endpoint, QuerysetEndpoint, api +from .endpoint import QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, FlowItem, PaginationItem, ConnectionItem -from ...filesys_helpers import ( +from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem + +io_types_r = (io.BytesIO, io.BufferedReader) +io_types_w = (io.BytesIO, io.BufferedWriter) io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 289ccdb11..ba5b6649b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, GroupItem, UserItem, PaginationItem, JobItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.groups") diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 6b709efad..dd210d990 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -2,9 +2,9 @@ from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException -from .. import JobItem, BackgroundJobItem, PaginationItem +from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem from ..request_options import RequestOptionsBase -from ...exponential_backoff import ExponentialBackoffTimer +from tableauserverclient.exponential_backoff import ExponentialBackoffTimer logger = logging.getLogger("tableau.endpoint.jobs") diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index fba2632a4..8443726cd 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -3,11 +3,10 @@ from .permissions_endpoint import _PermissionsEndpoint from .dqw_endpoint import _DataQualityWarningEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, PaginationItem -from ...models.metric_item import MetricItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import MetricItem, PaginationItem import logging -import copy from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e3e9af2a6..e50e32945 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -1,12 +1,12 @@ import logging -from .. import RequestFactory, PermissionsRule +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableauItem, PermissionsRule from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from ...models import TableauItem -from typing import Optional, Callable, TYPE_CHECKING, List, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union logger = logging.getLogger(__name__) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 7ccdcd775..440940606 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, RequestOptions, ProjectItem, PaginationItem, Resource +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index d5bc4dccb..18c38798e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -4,8 +4,8 @@ from .endpoint import Endpoint from .exceptions import EndpointUnavailableError, ServerResponseError -from .. import RequestFactory -from ...models.tag_item import TagItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TagItem logger = logging.getLogger("tableau.endpoint.resource_tagger") diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 3010eeb3a..7cca1f5d5 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -6,7 +6,8 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem logger = logging.getLogger("tableau.endpoint.schedules") AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 943aabee6..b396a1f87 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -6,7 +6,7 @@ ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from ...models import ServerInfoItem +from tableauserverclient.models import ServerInfoItem logger = logging.getLogger("tableau.endpoint.server_info") @@ -41,5 +41,9 @@ def get(self): raise EndpointUnavailableError(e) raise e - self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + try: + self._info = ServerInfoItem.from_response(server_response.content, self.parent_srv.namespace) + except Exception as e: + logging.getLogger(self.__class__.__name__).debug(e) + logging.getLogger(self.__class__.__name__).debug(server_response.content) return self._info diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 67d7db209..a4c765484 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -3,7 +3,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SiteItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SiteItem, PaginationItem logger = logging.getLogger("tableau.endpoint.sites") diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 6b929524e..a81a2fbf0 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, SubscriptionItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import SubscriptionItem, PaginationItem logger = logging.getLogger("tableau.endpoint.subscriptions") diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e41ab07ca..e51f885d7 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -4,7 +4,8 @@ from .endpoint import api, Endpoint from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint -from .. import RequestFactory, TableItem, ColumnItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager logger = logging.getLogger("tableau.endpoint.tables") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a70480b91..b903ac634 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -2,7 +2,8 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import TaskItem, PaginationItem, RequestFactory +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory logger = logging.getLogger("tableau.endpoint.tasks") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 3faf4d173..5a9c74619 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,16 +1,13 @@ import copy import logging -import os -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError -from .. import RequestFactory, RequestOptions, UserItem, WorkbookItem, PaginationItem, GroupItem +from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -# duplicate defined in workbooks_endpoint -FilePath = Union[str, os.PathLike] - logger = logging.getLogger("tableau.endpoint.users") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 06cc08349..c060298ba 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -5,7 +5,7 @@ from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import ViewItem, PaginationItem +from tableauserverclient.models import ViewItem, PaginationItem logger = logging.getLogger("tableau.endpoint.views") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index b28f3e5f1..69a958988 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,8 +1,8 @@ import logging from .endpoint import Endpoint, api -from .. import RequestFactory -from ...models import WebhookItem, PaginationItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem, PaginationItem logger = logging.getLogger("tableau.endpoint.webhooks") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index b7df3fcbb..295a4941f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -5,29 +5,21 @@ import os from contextlib import closing from pathlib import Path -from typing import ( - List, - Optional, - Sequence, - Tuple, - TYPE_CHECKING, - Union, -) from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from ...helpers import redact_xml from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from .. import RequestFactory, WorkbookItem, ConnectionItem, ViewItem, PaginationItem -from ...filesys_helpers import ( + +from tableauserverclient.filesys_helpers import ( to_filename, make_download_path, get_file_type, get_file_object_size, ) -from ...models.job_item import JobItem -from ...models.revision_item import RevisionItem +from tableauserverclient.helpers import redact_xml +from tableauserverclient.models import WorkbookItem, ConnectionItem, ViewItem, PaginationItem, JobItem, RevisionItem +from tableauserverclient.server import RequestFactory from typing import ( List, @@ -39,10 +31,9 @@ ) if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions - from .. import DatasourceItem - from ...models.connection_credentials import ConnectionCredentials + from tableauserverclient.server import Server + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.models import DatasourceItem, ConnectionCredentials from .schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 720eb4085..b19c3cc56 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,29 +1,12 @@ -from os import name import xml.etree.ElementTree as ET from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from tableauserverclient.models.metric_item import MetricItem - -from ..models import ConnectionCredentials -from ..models import ConnectionItem -from ..models import DataAlertItem -from ..models import FlowItem -from ..models import ProjectItem -from ..models import SiteItem -from ..models import SubscriptionItem -from ..models import TaskItem, UserItem, GroupItem, PermissionsRule, FavoriteItem -from ..models import WebhookItem +from tableauserverclient.models import * if TYPE_CHECKING: - from ..models import SubscriptionItem - from ..models import DataAlertItem - from ..models import FlowItem - from ..models import ConnectionItem - from ..models import SiteItem - from ..models import ProjectItem from tableauserverclient.server import Server @@ -1019,6 +1002,8 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["password"] = connection_item.password if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() + if connection_item.query_tagging is not None: + connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() class TaskRequest(object): @@ -1144,10 +1129,21 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) +class CustomViewRequest(object): + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): + updating_element = ET.SubElement(xml_request, "customView") + if custom_view_item.owner is not None and custom_view_item.owner.id is not None: + ET.SubElement(updating_element, "owner", {"id": custom_view_item.owner.id}) + if custom_view_item.name is not None: + updating_element.attrib["name"] = custom_view_item.name + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() + CustomView = CustomViewRequest() DataAlert = DataAlertRequest() Datasource = DatasourceRequest() Database = DatabaseRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f4ed8fd3c..baedd74de 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,4 @@ -from ..models.property_decorators import property_is_int +from tableauserverclient.models.property_decorators import property_is_int import logging logger = logging.getLogger("tableau.request_options") diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index d2a8b933b..887b9de6d 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,11 +1,12 @@ import logging -import warnings import requests import urllib3 from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version + +from . import CustomViews from .endpoint import ( Sites, Views, @@ -48,8 +49,9 @@ "9.1": "2.0", "9.0": "2.0", } + minimum_supported_server_version = "2.3" -default_server_version = "2.3" +default_server_version = "2.4" # first version that dropped the legacy auth endpoint class Server(object): @@ -95,6 +97,9 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._namespace = Namespace() self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) + self.custom_views = CustomViews(self) + + self.logger = logging.getLogger("TSC.server") self._session = self._session_factory() self._http_options = dict() # must set this before making a server call @@ -110,11 +115,14 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: Endpoint(self).set_parameters(self._http_options, None, None, None, None) + if not self._server_address.startswith("https://") and not self._server_address.startswith("https://"): + self._server_address = "https://" + self._server_address + self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) except Exception as req_ex: raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return " [Connection: {}, {}]".format(self.baseurl, self.server_info.serverInfo) + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -122,8 +130,7 @@ def add_http_options(self, options_dict: dict): if "verify" in options_dict.keys() and self._http_options.get("verify") is False: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # would be nice if you could turn them back on - except BaseException as be: - print(be) + except Exception as be: # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) @@ -144,43 +151,43 @@ def _set_auth(self, site_id, user_id, auth_token): self._auth_token = auth_token def _get_legacy_version(self): - dest = Endpoint(self) - response = dest._make_request(method=self.session.get, url=self.server_address + "/auth?format=xml") + # the serverInfo call was introduced in 2.4, earlier than that we have this different call + response = self._session.get(self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: - logging.getLogger("TSC.server").info(parseError) - logging.getLogger("TSC.server").info( - "Could not read server version info. The server may not be running or configured." - ) + self.logger.info(parseError) + self.logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text - version = _PRODUCT_TO_REST_VERSION.get(prod_version, "2.1") # 2.1 + version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) return version def _determine_highest_version(self): try: old_version = self.version - self.version = "2.4" version = self.server_info.get().rest_api_version - except ServerInfoEndpointNotFoundError: + except ServerInfoEndpointNotFoundError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - except BaseException: + except EndpointUnavailableError as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() - - self.version = old_version - - return version + except Exception as e: + self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + version = None + self.logger.info("versions: {}, {}".format(version, old_version)) + return version or old_version def use_server_version(self): self.version = self._determine_highest_version() def use_highest_version(self): self.use_server_version() - warnings.warn("use use_server_version instead", DeprecationWarning) + self.logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): - server_version = Version(self.version or "0.0") + server_version = Version(self.version or "2.4") target_version = Version(target) return server_version >= target_version diff --git a/test/assets/custom_view_get.xml b/test/assets/custom_view_get.xml new file mode 100644 index 000000000..67e342f30 --- /dev/null +++ b/test/assets/custom_view_get.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test/assets/custom_view_get_id.xml b/test/assets/custom_view_get_id.xml new file mode 100644 index 000000000..14e589b8d --- /dev/null +++ b/test/assets/custom_view_get_id.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/custom_view_update.xml b/test/assets/custom_view_update.xml new file mode 100644 index 000000000..5ab85bc05 --- /dev/null +++ b/test/assets/custom_view_update.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/server_info_get.xml b/test/assets/server_info_get.xml index ce4e0b322..94218502a 100644 --- a/test/assets/server_info_get.xml +++ b/test/assets/server_info_get.xml @@ -1,6 +1,6 @@ 10.1.0 -2.4 +3.10 - \ No newline at end of file + diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index bf9292dec..ce845502d 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -11,6 +11,8 @@ def mocked_requests_get(*args, **kwargs): class MockResponse: def __init__(self, status_code): + self.headers = {} + self.encoding = None self.content = ( "" "" @@ -43,9 +45,9 @@ def test_init_server_model_valid_https_server_name_works(self): def test_init_server_model_bad_server_name_not_version_check(self): server = TSC.Server("fake-url", use_server_version=False) - def test_init_server_model_bad_server_name_do_version_check(self): - with self.assertRaises(requests.exceptions.ConnectionError): - server = TSC.Server("fake-url", use_server_version=True) + @mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get) + def test_init_server_model_bad_server_name_do_version_check(self, mock_get): + server = TSC.Server("fake-url", use_server_version=True) def test_init_server_model_bad_server_name_not_version_check_random_options(self): # with self.assertRaises(MissingSchema): diff --git a/test/models/_models.py b/test/models/_models.py new file mode 100644 index 000000000..a1630da9c --- /dev/null +++ b/test/models/_models.py @@ -0,0 +1,61 @@ +from tableauserverclient import * + +# mmm. why aren't these available in the tsc namespace? +from tableauserverclient.models import ( + DataAccelerationReportItem, + FavoriteItem, + Credentials, + ServerInfoItem, + Resource, + TableauItem, + plural_type, +) + + +def get_defined_models(): + # not clever: copied from tsc/models/__init__.py + return [ + ColumnItem, + ConnectionCredentials, + ConnectionItem, + DataAccelerationReportItem, + DataAlertItem, + DatabaseItem, + DatasourceItem, + DQWItem, + UnpopulatedPropertyError, + FavoriteItem, + FlowItem, + FlowRunItem, + GroupItem, + IntervalItem, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + JobItem, + BackgroundJobItem, + MetricItem, + PaginationItem, + PermissionsRule, + Permission, + ProjectItem, + RevisionItem, + ScheduleItem, + ServerInfoItem, + SiteItem, + SubscriptionItem, + TableItem, + Credentials, + TableauAuth, + PersonalAccessTokenAuth, + Resource, + TableauItem, + plural_type, + Target, + TaskItem, + UserItem, + ViewItem, + WebhookItem, + WorkbookItem, + ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py new file mode 100644 index 000000000..f3da9fde2 --- /dev/null +++ b/test/models/test_repr.py @@ -0,0 +1,40 @@ +import pytest + +from unittest import TestCase +import _models + + +# ensure that all models have a __repr__ method implemented +class TestAllModels(TestCase): + + """ + ColumnItem wrapper_descriptor + ConnectionCredentials wrapper_descriptor + DataAccelerationReportItem wrapper_descriptor + DatabaseItem wrapper_descriptor + DQWItem wrapper_descriptor + UnpopulatedPropertyError wrapper_descriptor + FavoriteItem wrapper_descriptor + FlowRunItem wrapper_descriptor + IntervalItem wrapper_descriptor + DailyInterval wrapper_descriptor + WeeklyInterval wrapper_descriptor + MonthlyInterval wrapper_descriptor + HourlyInterval wrapper_descriptor + BackgroundJobItem wrapper_descriptor + PaginationItem wrapper_descriptor + Permission wrapper_descriptor + ServerInfoItem wrapper_descriptor + SiteItem wrapper_descriptor + TableItem wrapper_descriptor + Resource wrapper_descriptor + """ + + # not all models have __repr__ yet: see above list + @pytest.mark.xfail() + def test_repr_is_implemented(self): + m = _models.get_defined_models() + for model in m: + with self.subTest(model.__name__, model=model): + print(model.__name__, type(model.__repr__).__name__) + self.assertEqual(type(model.__repr__).__name__, "function") diff --git a/test/test_connection_.py b/test/test_connection_.py new file mode 100644 index 000000000..47b796ebe --- /dev/null +++ b/test/test_connection_.py @@ -0,0 +1,34 @@ +import unittest +import tableauserverclient as TSC + + +class DatasourceModelTests(unittest.TestCase): + def test_require_boolean_query_tag_fails(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + with self.assertRaises(ValueError): + conn.query_tagging = "no" + + def test_set_query_tag_normal_conn(self): + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, True) + + def test_ignore_query_tag_for_hyper(self): + conn = TSC.ConnectionItem() + conn._connection_type = "hyper" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_teradata(self): + conn = TSC.ConnectionItem() + conn._connection_type = "teradata" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) + + def test_ignore_query_tag_for_snowflake(self): + conn = TSC.ConnectionItem() + conn._connection_type = "snowflake" + conn.query_tagging = True + self.assertEqual(conn.query_tagging, None) diff --git a/test/test_custom_view.py b/test/test_custom_view.py new file mode 100644 index 000000000..c1fe8c407 --- /dev/null +++ b/test/test_custom_view.py @@ -0,0 +1,133 @@ +import os +import unittest + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") +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") + + +class CustomViewTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.19" # custom views only introduced in 3.19 + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.custom_views.baseurl + + def test_get(self) -> None: + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + print(response_xml) + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_views, pagination_item = self.server.custom_views.get() + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) + self.assertEqual("ENDANGERED SAFARI", all_views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook.id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) + self.assertIsNone(all_views[0].created_at) + self.assertIsNone(all_views[0].updated_at) + + self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) + self.assertEqual("Overview", all_views[1].name) + self.assertEqual(False, all_views[1].shared) + self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + + def test_get_by_id(self) -> None: + with open(GET_XML_ID, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view: TSC.CustomViewItem = self.server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + if view.workbook: + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook.id) + if view.owner: + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner.id) + if view.view: + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.view.id) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + + def test_get_by_id_missing_id(self) -> None: + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.get_by_id, None) + + def test_get_before_signin(self) -> None: + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.custom_views.get) + + def test_populate_image(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_image(single_view) + self.assertEqual(response, single_view.image) + + def test_populate_image_with_options(self) -> None: + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) + self.server.custom_views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + def test_populate_image_missing_id(self) -> None: + single_view = TSC.CustomViewItem() + single_view._id = None + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.populate_image, single_view) + + def test_delete(self) -> None: + with requests_mock.mock() as m: + m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + self.server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + def test_delete_missing_id(self) -> None: + self.assertRaises(ValueError, self.server.custom_views.delete, "") + + def test_update(self) -> None: + with open(CUSTOM_VIEW_UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") + the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" + the_custom_view.owner = TSC.UserItem() + the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + the_custom_view = self.server.custom_views.update(the_custom_view) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", the_custom_view.id) + if the_custom_view.owner: + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", the_custom_view.owner.id) + self.assertEqual("Best test ever", the_custom_view.name) + + def test_update_missing_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 81a26b068..2360574ec 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,5 +1,4 @@ import unittest - import tableauserverclient as TSC @@ -9,3 +8,13 @@ def test_invalid_project_id(self): datasource = TSC.DatasourceItem("10") with self.assertRaises(ValueError): datasource.project_id = None + + def test_require_boolean_flag_bridge_fail(self): + datasource = TSC.DatasourceItem("10") + with self.assertRaises(ValueError): + datasource.use_remote_query_agent = "yes" + + def test_require_boolean_flag_bridge_ok(self): + datasource = TSC.DatasourceItem("10") + datasource.use_remote_query_agent = True + self.assertEqual(datasource.use_remote_query_agent, True) diff --git a/test/test_server_info.py b/test/test_server_info.py index 80b071e75..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -28,7 +28,7 @@ def test_server_info_get(self): self.assertEqual("10.1.0", actual.product_version) self.assertEqual("10100.16.1024.2100", actual.build_number) - self.assertEqual("2.4", actual.rest_api_version) + self.assertEqual("3.10", actual.rest_api_version) def test_server_info_use_highest_version_downgrades(self): with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: @@ -42,18 +42,19 @@ def test_server_info_use_highest_version_downgrades(self): m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) self.server.use_server_version() + # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION self.assertEqual(self.server.version, "2.2") def test_server_info_use_highest_version_upgrades(self): with open(SERVER_INFO_GET_XML, "rb") as f: si_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml) + m.get(self.server.server_address + "/api/2.8/serverInfo", text=si_response_xml) # Pretend we're old - self.server.version = "2.0" + self.server.version = "2.8" self.server.use_server_version() - # Did we upgrade to 2.4? - self.assertEqual(self.server.version, "2.4") + # Did we upgrade to 3.10? + self.assertEqual(self.server.version, "3.10") def test_server_use_server_version_flag(self): with open(SERVER_INFO_25_XML, "rb") as f: diff --git a/test/test_user_model.py b/test/test_user_model.py index fcb9b7f90..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -10,16 +10,6 @@ class UserModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.UserItem, None, TSC.UserItem.Roles.Publisher) - self.assertRaises(ValueError, TSC.UserItem, "", TSC.UserItem.Roles.Publisher) - user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) - with self.assertRaises(ValueError): - user.name = None - - with self.assertRaises(ValueError): - user.name = "" - def test_invalid_auth_setting(self): user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) with self.assertRaises(ValueError): diff --git a/test/test_workbook.py b/test/test_workbook.py index 8711ba15e..5114ce1b8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1,20 +1,15 @@ import os import re import requests_mock -import tableauserverclient as TSC import tempfile import unittest -import xml.etree.ElementTree as ET - from defusedxml.ElementTree import fromstring from io import BytesIO from pathlib import Path import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.models.group_item import GroupItem -from tableauserverclient.models.permissions_item import PermissionsRule -from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset From fc9568de2e9b5e566120c2f20fe2f678df6a782e Mon Sep 17 00:00:00 2001 From: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Date: Wed, 12 Apr 2023 20:26:42 +0100 Subject: [PATCH 299/567] Update user_item.py (#1217) TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled and to keep in sync with REST API. Fixing Issue #1216 --- tableauserverclient/models/user_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5e3d18fa6..a12f4b557 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -45,6 +45,7 @@ class Roles: class Auth: OpenID = "OpenID" SAML = "SAML" + TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" def __init__( From 4fb61806fc3e0b4fe7804c0de9d2a3f3f02a054b Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 12 Apr 2023 18:22:33 -0700 Subject: [PATCH 300/567] Jac/small things (#1215) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 --- tableauserverclient/models/datasource_item.py | 7 +++---- .../server/endpoint/workbooks_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 19 ++++++++++++++----- tableauserverclient/server/request_options.py | 3 ++- test/test_datasource_model.py | 8 +++----- test/test_view.py | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b5568a778..dbaa0ff91 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -32,7 +32,7 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None @@ -135,12 +135,11 @@ def id(self) -> Optional[str]: return self._id @property - def project_id(self) -> str: + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable - def project_id(self, value: str): + def project_id(self, value: Optional[str]): self._project_id = value @property diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 295a4941f..5e2784b55 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -309,6 +309,7 @@ def publish( as_job: bool = False, hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, + parameters=None, ): if connection_credentials is not None: import warnings @@ -412,7 +413,7 @@ def publish( # Send the publishing request to server try: - server_response = self.post_request(url, xml_request, content_type) + server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: if err.code == 504 and not as_job: err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b19c3cc56..050874c91 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server +# this file could be largely replaced if we were willing to import the huge file from generateDS + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -146,10 +148,11 @@ def update_req(self, database_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): + def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") - datasource_element.attrib["name"] = datasource_item.name + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: @@ -157,10 +160,16 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") - ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__() - project_element = ET.SubElement(datasource_element, "project") - project_element.attrib["id"] = datasource_item.project_id + if datasource_item.certified: + datasource_element.attrib["isCertified"] = datasource_item.certified.__str__() + if datasource_item.certification_note: + datasource_element.attrib["certificationNote"] = datasource_item.certification_note + + if datasource_item.project_id: + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index baedd74de..299b9db2f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: class Field: Args = "args" CompletedAt = "completedAt" + ContentUrl = "contentUrl" CreatedAt = "createdAt" DomainName = "domainName" DomainNickname = "domainNickname" @@ -147,7 +148,7 @@ def get_query_params(self): return params -class ExcelRequestOptions(RequestOptionsBase): +class ExcelRequestOptions(_FilterOptionsBase): def __init__(self, maxage: int = -1) -> None: super().__init__() self.max_age = maxage diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 2360574ec..655284194 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -3,11 +3,9 @@ class DatasourceModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.DatasourceItem, None) - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.project_id = None + def test_nullable_project_id(self): + datasource = TSC.DatasourceItem(name="10") + self.assertEqual(datasource.project_id, None) def test_require_boolean_flag_bridge_fail(self): datasource = TSC.DatasourceItem("10") diff --git a/test/test_view.py b/test/test_view.py index f5d3db47b..1459150bb 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -299,3 +299,19 @@ def test_populate_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_filter_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) From 3cc28be8e18af0f36dfd390c7c3a306e5d90f6a0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 301/567] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .gitignore | 1 + samples/add_default_permission.py | 10 +- samples/create_group.py | 10 +- samples/create_project.py | 10 +- samples/create_schedules.py | 10 +- samples/explore_datasource.py | 10 +- samples/explore_site.py | 10 +- samples/explore_webhooks.py | 10 +- samples/explore_workbook.py | 10 +- samples/export.py | 10 +- samples/extracts.py | 10 +- samples/filter_sort_groups.py | 10 +- samples/filter_sort_projects.py | 10 +- samples/initialize_server.py | 14 +-- samples/kill_all_jobs.py | 10 +- samples/list.py | 10 +- samples/login.py | 21 +++- samples/metadata_query.py | 10 +- samples/move_workbook_projects.py | 14 +-- samples/move_workbook_sites.py | 14 +-- samples/pagination_sample.py | 10 +- samples/publish_datasource.py | 40 +++++-- samples/publish_workbook.py | 12 +- samples/query_permissions.py | 10 +- samples/refresh.py | 10 +- samples/refresh_tasks.py | 10 +- samples/set_refresh_schedule.py | 10 +- samples/smoke_test.py | 10 +- samples/update_connection.py | 10 +- samples/update_datasource_data.py | 10 +- tableauserverclient/config.py | 13 +++ tableauserverclient/datetime_helpers.py | 4 + tableauserverclient/helpers/logging.py | 6 + tableauserverclient/models/connection_item.py | 2 +- tableauserverclient/models/favorites_item.py | 3 +- tableauserverclient/models/fileupload_item.py | 4 +- .../models/permissions_item.py | 2 +- .../models/server_info_item.py | 2 +- tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/auth_endpoint.py | 2 +- .../server/endpoint/custom_views_endpoint.py | 2 +- .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 2 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 36 +++--- .../endpoint/default_permissions_endpoint.py | 2 +- .../server/endpoint/dqw_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 109 ++++++++++++++++-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 2 +- .../server/endpoint/fileuploads_endpoint.py | 21 ++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 10 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metadata_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/permissions_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 6 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/tasks_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/exceptions.py | 9 +- tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 25 ++-- test/test_auth.py | 6 +- test/test_datasource.py | 6 +- test/test_endpoint.py | 25 +++- test/test_fileuploads.py | 4 +- test/test_request_option.py | 6 +- test/test_webhook.py | 3 +- 80 files changed, 395 insertions(+), 327 deletions(-) create mode 100644 tableauserverclient/config.py create mode 100644 tableauserverclient/helpers/logging.py diff --git a/.gitignore b/.gitignore index d8caf99a9..f0226c065 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ celerybeat-schedule # dotenv .env +env.py # virtualenv venv/ diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8a87c1fd6..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_group.py b/samples/create_group.py index 2229f7f26..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_project.py b/samples/create_project.py index 8b2ec3354..611dbe366 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -28,14 +28,10 @@ def create_project(server, project_item, samples=False): def main(): parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_schedules.py b/samples/create_schedules.py index f193352de..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index aafbe167c..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_site.py b/samples/explore_site.py index a181abfec..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 47e59ac06..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f242ace70..c61b9b637 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/export.py b/samples/export.py index 4c26770b9..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/extracts.py b/samples/extracts.py index c77da89d0..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", help="site name") - parser.add_argument( - "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 984d8d344..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -26,14 +26,10 @@ def create_example_group(group_name="Example Group", server=None): def main(): parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 608f472ba..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -29,14 +29,10 @@ def create_example_project( def main(): parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/initialize_server.py b/samples/initialize_server.py index e7ed0139f..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -29,8 +25,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") - parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--datasources-folder", "-df", help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks") parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1a833f938..bfebb49b8 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/list.py b/samples/list.py index b5cdb38a5..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/login.py b/samples/login.py index f3e9d77dc..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -9,6 +9,7 @@ import logging import tableauserverclient as TSC +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -18,10 +19,15 @@ def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") sample_define_common_options(parser) args = parser.parse_args() - - # Set logging level based on user input, or error by default. - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging_level = "debug" server = sample_connect_to_server(args) print(server.server_info.get()) @@ -30,9 +36,9 @@ def set_up_and_log_in(): def sample_define_common_options(parser): # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=True) + auth = parser.add_mutually_exclusive_group(required=False) auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") auth.add_argument("--username", "-u", help="username to sign into the server") @@ -73,6 +79,9 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) print("Logged in successfully") return server diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 26f8f94fa..7524453c2 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index be49ec23b..392dc0ff8 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -33,8 +29,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-project", "-d", help="name of project to move workbook into") args = parser.parse_args() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 3feb62be2..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -22,14 +22,10 @@ def main(): "the default project of another site." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -38,8 +34,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") args = parser.parse_args() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index b55fef320..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8d9e59ea2..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -23,18 +23,17 @@ import tableauserverclient as TSC +import env +import tableauserverclient.datetime_helpers + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -43,7 +42,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") parser.add_argument("--conn-username", help="connection username") @@ -52,14 +51,27 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging = "debug" + args.file = "C:/dev/tab-samples/5M.tdsx" + args.async_ = True # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(logging.StreamHandler()) # Sign in to server tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) @@ -94,6 +106,7 @@ def main(): # Publish datasource if args.async_: + print("Publish as a job") # Async publishing, returns a job_item new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True @@ -104,7 +117,12 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) - print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + print( + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) + ) + print("\t\tClosing connection") if __name__ == "__main__": diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index f0edc380c..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -24,14 +24,10 @@ def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -40,7 +36,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 7106da934..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/refresh.py b/samples/refresh.py index f90441224..d3e49ed24 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 2bfc85621..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -30,14 +30,10 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 9b3dbc236..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -15,14 +15,10 @@ def usage(args): parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/smoke_test.py b/samples/smoke_test.py index f2dad1048..b23eacdb8 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,8 +1,16 @@ # This sample verifies that tableau server client is installed # and you can run it. It also shows the version of the client. +import logging import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + server = TSC.Server("Fake-Server-Url", use_server_version=False) print("Client details:") -print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_connection.py b/samples/update_connection.py index e27b4477f..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 41f42ee74..f6bc92022 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -25,14 +25,10 @@ def main(): description="Delete the `Europe` region from a published `World Indicators` datasource." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py new file mode 100644 index 000000000..67a77f479 --- /dev/null +++ b/tableauserverclient/config.py @@ -0,0 +1,13 @@ +# TODO: check for env variables, else set default values + +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] + +BYTES_PER_MB = 1024 * 1024 + +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks +CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 + +DELAY_SLEEP_SECONDS = 10 + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0d968428d..00f62faf8 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,6 +5,10 @@ HOUR = datetime.timedelta(hours=1) +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py new file mode 100644 index 000000000..414d85786 --- /dev/null +++ b/tableauserverclient/helpers/logging.py @@ -0,0 +1,6 @@ +import logging + +# TODO change: this defaults to logging *everything* to stdout +logger = logging.getLogger("TSC") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 4ed06b831..29ffd2700 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -5,6 +5,7 @@ from .connection_credentials import ConnectionCredentials from .property_decorators import property_is_boolean +from tableauserverclient.helpers.logging import logger class ConnectionItem(object): @@ -46,7 +47,6 @@ def query_tagging(self) -> Optional[bool]: def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: - logger = logging.getLogger("tableauserverclient.models.connection_item") logger.debug( "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index afa769fd9..dd9dcfaed 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -8,8 +8,7 @@ from .view_item import ViewItem from .workbook_item import WorkbookItem -logger = logging.getLogger("tableau.models.favorites_item") - +from tableauserverclient.helpers.logging import logger from typing import Dict, List, Union FavoriteType = Dict[ diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index 7848b94cf..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -11,8 +11,8 @@ def upload_session_id(self): return self._upload_session_id @property - def file_size(self): - return self._file_size + def file_size(self) -> int: + return int(self._file_size) @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3bdc63092..1602b077f 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -9,7 +9,7 @@ from .reference_item import ResourceReference from .user_item import UserItem -logger = logging.getLogger("tableau.models.permissions_item") +from tableauserverclient.helpers.logging import logger class Permission: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5f9395880..b180665dd 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -3,6 +3,7 @@ import xml from defusedxml.ElementTree import fromstring +from tableauserverclient.helpers.logging import logger class ServerInfoItem(object): @@ -36,7 +37,6 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index bcea2604e..5abe19446 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,9 +10,7 @@ from .filter import Filter from .sort import Sort -from ..models import * from .endpoint import * from .server import Server from .pager import Pager -from .exceptions import NotSignedInError -from ..helpers import * +from .endpoint.exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e8e1bc0f9..c018d8334 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,11 +5,7 @@ from .databases_endpoint import Databases from .datasources_endpoint import Datasources from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ( - ServerResponseError, - MissingRequiredFieldError, - ServerInfoEndpointNotFoundError, -) +from .exceptions import ServerResponseError, MissingRequiredFieldError from .favorites_endpoint import Favorites from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 68d75eaa8..6f1ddc35e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import ServerResponseError from ..request_factory import RequestFactory -logger = logging.getLogger("tableau.endpoint.auth") +from tableauserverclient.helpers.logging import logger class Auth(Endpoint): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 778cafecc..119580609 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions -logger = logging.getLogger("tableau.endpoint.custom_views") +from tableauserverclient.helpers.logging import logger """ Get a list of custom views on a site diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 28e5495c5..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -5,7 +5,7 @@ from .permissions_endpoint import _PermissionsEndpoint from tableauserverclient.models import DataAccelerationReportItem -logger = logging.getLogger("tableau.endpoint.data_acceleration_report") +from tableauserverclient.helpers.logging import logger class DataAccelerationReport(Endpoint): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 5af4e0464..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem -logger = logging.getLogger("tableau.endpoint.dataAlerts") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2522ef53e..125996277 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource -logger = logging.getLogger("tableau.endpoint.databases") +from tableauserverclient.helpers.logging import logger class Databases(Endpoint): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0c5b8ba61..c60f8f919 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,7 +1,6 @@ import cgi import copy import json -import logging import io import os @@ -20,13 +19,14 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( - to_filename, make_download_path, get_file_type, get_file_object_size, + to_filename, ) +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( ConnectionCredentials, ConnectionItem, @@ -35,6 +35,7 @@ RevisionItem, PaginationItem, ) +from tableauserverclient.server import RequestFactory, RequestOptions io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) @@ -44,13 +45,6 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB - -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] - -logger = logging.getLogger("tableau.endpoint.datasources") - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -162,12 +156,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: + def update_connection( + self, datasource_item: DatasourceItem, connection_item: ConnectionItem + ) -> Optional[ConnectionItem]: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) - connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + if not connections: + return None + + if len(connections) > 1: + logger.debug("Multiple connections returned ({0})".format(len(connections))) + connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] logger.info( "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) @@ -220,7 +222,7 @@ def publish( filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -261,8 +263,12 @@ def publish( url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + logger.info( + "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( + filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + ) + ) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index b0d16efaf..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -10,7 +10,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type BaseItem = Union[DatabaseItem, ProjectItem] diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 96cb7c5f9..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DQWItem -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger class _DataQualityWarningEndpoint(Endpoint): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9c933c9dd..c11a3fb27 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,24 +1,31 @@ +from threading import Thread +from time import sleep +from tableauserverclient import datetime_helpers as datetime + import requests -import logging from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, - EndpointUnavailableError, + NotSignedInError, ) +from ..exceptions import EndpointUnavailableError + from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions +from tableauserverclient.helpers.logging import logger +from tableauserverclient.config import DELAY_SLEEP_SECONDS + if TYPE_CHECKING: from ..server import Server from requests import Response -logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -34,6 +41,8 @@ class Endpoint(object): def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv + async_response = None + @staticmethod def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} @@ -53,6 +62,8 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) @staticmethod def set_user_agent(parameters): + if "headers" not in parameters: + parameters["headers"] = {} if USER_AGENT_HEADER not in parameters["headers"]: if USER_AGENT_HEADER in parameters: parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] @@ -65,6 +76,59 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters + def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + self.async_response = None + response = None + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + try: + response = method(url, **parameters) + self.async_response = response + logger.debug("[{}] Call finished".format(datetime.timestamp())) + except Exception as e: + logger.debug("Error making request to server: {}".format(e)) + self.async_response = e + finally: + if response and not self.async_response: + logger.debug("Request response not saved") + return None + logger.debug("[{}] Request complete".format(datetime.timestamp())) + return self.async_response + + def send_request_while_show_progress_threaded( + self, method, url, parameters={}, request_timeout=0 + ) -> Optional["Response"]: + try: + request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) + request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms + request_thread.start() + except Exception as e: + logger.debug("Error starting server request on separate thread: {}".format(e)) + return None + seconds = 0 + minutes = 0 + sleep(1) + if self.async_response != -1: + # a quick return for any immediate responses + return self.async_response + while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + self.log_wait_time_then_sleep(minutes, seconds, url) + seconds = seconds + DELAY_SLEEP_SECONDS + if seconds >= 60: + seconds = 0 + minutes = minutes + 1 + return self.async_response + + def log_wait_time_then_sleep(self, minutes, seconds, url): + logger.debug("{} Waiting....".format(datetime.timestamp())) + if seconds >= 60: # detailed log message ~every minute + if minutes % 5 == 0: + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + sleep(DELAY_SLEEP_SECONDS) + def _make_request( self, method: Callable[..., "Response"], @@ -80,36 +144,59 @@ def _make_request( logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: - redacted = helpers.strings.redact_xml(content[:1000]) + redacted = helpers.strings.redact_xml(content[:200]) + # this needs to be under a trace or something, it's a LOT # logger.debug("request content: {}".format(redacted)) - server_response = method(url, **parameters) + # a request can, for stuff like publishing, spin for ages waiting for a response. + # we need some user-facing activity so they know it's not dead. + request_timeout = self.parent_srv.http_options.get("timeout") or 0 + server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + method, url, parameters, request_timeout + ) + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + # is this blocking retry really necessary? I guess if it was just the threading messing it up? + if server_response is None: + logger.debug(server_response) + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + server_response = self._blocking_request(method, url, parameters) + if server_response is None: + logger.debug("[{}] Request failed".format(datetime.timestamp())) + raise RuntimeError self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + logger.debug("Server response from {0}".format(url)) + # logger.debug("\n\t{1}".format(loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) return server_response - def _check_status(self, server_response, url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: Optional[str] = None): + logger.debug("Response status: {}".format(server_response)) + if not hasattr(server_response, "status_code"): + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: + if server_response.status_code == 401: + # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry + raise NotSignedInError(server_response.content, url) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that doesn't return an xml error object - # e.g metadata endpoints, 503 pages, totally different servers + # e.g. metadata endpoints, 503 pages, totally different servers # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here raise - def log_response_safely(self, server_response: requests.Response) -> str: + def log_response_safely(self, server_response: "Response") -> str: # Checking the content type header prevents eager evaluation of streaming requests. content_type = server_response.headers.get("Content-Type") @@ -117,7 +204,7 @@ def log_response_safely(self, server_response: requests.Response) -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type {}".format(content_type) + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d7b1d5ad2..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(TableauError): - pass - - -class EndpointUnavailableError(TableauError): +class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5105b3bf4..81bb468f8 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -10,7 +10,7 @@ from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.favorites") +from tableauserverclient.helpers.logging import logger class Favorites(Endpoint): diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 9a8e9560d..a0e29e508 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,13 +1,10 @@ -import logging - from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FileuploadItem +from tableauserverclient import datetime_helpers as datetime +from tableauserverclient.helpers.logging import logger -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE = 1024 * 1024 * 5 # 5MB - -logger = logging.getLogger("tableau.endpoint.fileuploads") +from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.models import FileuploadItem +from tableauserverclient.server import RequestFactory class Fileuploads(Endpoint): @@ -44,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content @@ -55,8 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3bca93a7f..63b32e006 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.flowruns") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 4d97110c4..ba8a152d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -32,13 +32,13 @@ ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger("tableau.endpoint.flows") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from .. import DQWItem - from ..request_options import RequestOptions - from ...models.permissions_item import PermissionsRule - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DQWItem + from tableauserverclient.models.permissions_item import PermissionsRule + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ba5b6649b..ad3828568 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.groups") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index dd210d990..d0b865e21 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,7 +6,7 @@ from ..request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.jobs") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, Tuple, Union diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 06339fa79..39146d062 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError, InvalidGraphQLQuery -logger = logging.getLogger("tableau.endpoint.metadata") +from tableauserverclient.helpers.logging import logger def is_valid_paged_query(parsed_query): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 8443726cd..a0e984475 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -15,7 +15,7 @@ from ...server import Server -logger = logging.getLogger("tableau.endpoint.metrics") +from tableauserverclient.helpers.logging import logger class Metrics(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e50e32945..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -8,7 +8,7 @@ from typing import Callable, TYPE_CHECKING, List, Optional, Union -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 440940606..510f1ff3d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -13,7 +13,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.projects") +from tableauserverclient.helpers.logging import logger class Projects(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 18c38798e..8177bd733 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,13 +1,13 @@ import copy -import logging import urllib.parse from .endpoint import Endpoint -from .exceptions import EndpointUnavailableError, ServerResponseError +from .exceptions import ServerResponseError +from ..exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem -logger = logging.getLogger("tableau.endpoint.resource_tagger") +from tableauserverclient.helpers.logging import logger class _ResourceTagger(Endpoint): diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 7cca1f5d5..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -9,7 +9,8 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem -logger = logging.getLogger("tableau.endpoint.schedules") +from tableauserverclient.helpers.logging import logger + AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index b396a1f87..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,15 +1,13 @@ import logging from .endpoint import Endpoint, api -from .exceptions import ( - ServerResponseError, +from .exceptions import ServerResponseError +from ..exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) from tableauserverclient.models import ServerInfoItem -logger = logging.getLogger("tableau.endpoint.server_info") - class ServerInfo(Endpoint): def __init__(self, server): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index a4c765484..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SiteItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.sites") +from tableauserverclient.helpers.logging import logger from typing import TYPE_CHECKING, List, Optional, Tuple diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a81a2fbf0..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SubscriptionItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.subscriptions") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e51f885d7..dfb2e6d7c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.tables") +from tableauserverclient.helpers.logging import logger class Tables(Endpoint): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index b903ac634..ad1702f58 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory -logger = logging.getLogger("tableau.endpoint.tasks") +from tableauserverclient.helpers.logging import logger class Tasks(Endpoint): diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5a9c74619..e8c5cc962 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.users") +from tableauserverclient.helpers.logging import logger class Users(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c060298ba..9c4b90657 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from .resource_tagger import _ResourceTagger from tableauserverclient.models import ViewItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.views") +from tableauserverclient.helpers.logging import logger from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 69a958988..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.webhooks") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 295a4941f..dc4adafaa 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -44,7 +44,8 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger("tableau.endpoint.workbooks") +from tableauserverclient.helpers.logging import logger + FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] FileObjectR = Union[io.BufferedReader, io.BytesIO] diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py index 09d3d0541..6c9bbcefc 100644 --- a/tableauserverclient/server/exceptions.py +++ b/tableauserverclient/server/exceptions.py @@ -1,2 +1,9 @@ -class NotSignedInError(Exception): +# These errors can be thrown without even talking to Tableau Server + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): pass diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index baedd74de..fa0a2d68a 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,7 +1,7 @@ from tableauserverclient.models.property_decorators import property_is_int import logging -logger = logging.getLogger("tableau.request_options") +from tableauserverclient.helpers.logging import logger class RequestOptionsBase(object): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 887b9de6d..ee23789b1 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,4 +1,4 @@ -import logging +from tableauserverclient.helpers.logging import logger import requests import urllib3 @@ -34,11 +34,11 @@ Metrics, Endpoint, ) -from .endpoint.exceptions import ( +from .exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .exceptions import NotSignedInError +from .endpoint.exceptions import NotSignedInError from ..namespace import Namespace @@ -99,8 +99,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) - self.logger = logging.getLogger("TSC.server") - self._session = self._session_factory() self._http_options = dict() # must set this before making a server call if http_options: @@ -114,7 +112,8 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: - Endpoint(self).set_parameters(self._http_options, None, None, None, None) + params = Endpoint(self).set_parameters(self._http_options, None, None, None, None) + Endpoint.set_user_agent(params) if not self._server_address.startswith("https://") and not self._server_address.startswith("https://"): self._server_address = "https://" + self._server_address self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) @@ -156,8 +155,8 @@ def _get_legacy_version(self): try: info_xml = fromstring(response.content) except ParseError as parseError: - self.logger.info(parseError) - self.logger.info("Could not read server version info. The server may not be running or configured.") + logger.info(parseError) + logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) @@ -168,15 +167,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - self.logger.info("versions: {}, {}".format(version, old_version)) + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -184,7 +183,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - self.logger.info("use use_server_version instead", DeprecationWarning) + logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): server_version = Version(self.version or "2.4") diff --git a/test/test_auth.py b/test/test_auth.py index 40255f627..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 4f3529762..730e382da 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -145,9 +145,9 @@ def test_update_copy_fields(self) -> None: def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -191,7 +191,7 @@ def test_update_connection(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", text=response_xml, ) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) @@ -610,7 +610,7 @@ def test_synchronous_publish_timeout_error(self) -> None: new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds self.assertRaisesRegex( InternalServerError, "Please use asynchronous publishing to avoid timeouts.", diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0d8ae84f2..3d2d1c995 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -15,9 +15,32 @@ def setUp(self) -> None: # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + def test_fallback_request_logic(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + self.assertIsNotNone(response) + + def test_user_friendly_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + self.assertIsNotNone(response) + + def test_blocking_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) + def test_get_request_stream(self) -> None: url = "http://test/" endpoint = TSC.server.Endpoint(self.server) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 4d3b0c864..cf0861e24 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -43,7 +43,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -58,7 +58,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9dacbe033..5d8bdf05e 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -22,7 +22,7 @@ class RequestOptionTests(unittest.TestCase): def setUp(self) -> None: - self.server = TSC.Server("http://test", False) + self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) # Fake signin self.server.version = "3.10" @@ -151,7 +151,7 @@ def test_multiple_filter_options(self) -> None: ) ) req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(100): + for _ in range(5): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -245,7 +245,7 @@ def test_multiple_filter_options_shorthand(self) -> None: ) m.get(url, text=response_xml) - for _ in range(100): + for _ in range(5): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) diff --git a/test/test_webhook.py b/test/test_webhook.py index ff8b7048e..5f26266b2 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -4,7 +4,8 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server import RequestFactory, WebhookItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") From cc62a50372910c92e4189e7ac0b48a49395fd128 Mon Sep 17 00:00:00 2001 From: Nicole Arcolino <107720857+narcolino-tableau@users.noreply.github.com> Date: Tue, 18 Apr 2023 20:09:45 -0700 Subject: [PATCH 302/567] adding sample file for favorites, issue #737 (#1085) * adding sample file for favorites, issue #737 * add metrics to favorites Cleaned up the from_response method for several items because we can now initialize them empty Co-authored-by: Jac Fitzgerald Co-authored-by: Nicole Arcolino --- samples/explore_favorites.py | 85 ++++++++++++++++ tableauserverclient/models/datasource_item.py | 48 ++-------- tableauserverclient/models/favorites_item.py | 94 ++++++++++-------- tableauserverclient/models/flow_item.py | 55 ++++++----- tableauserverclient/models/metric_item.py | 60 ++++++------ tableauserverclient/models/project_item.py | 22 ++--- tableauserverclient/models/tableau_types.py | 6 +- tableauserverclient/models/user_item.py | 1 + tableauserverclient/models/view_item.py | 70 +++++++------- tableauserverclient/models/workbook_item.py | 52 ++-------- .../server/endpoint/favorites_endpoint.py | 96 ++++++++++++++----- tableauserverclient/server/request_factory.py | 19 ++-- test/test_favorites.py | 2 + test/test_project.py | 2 +- test/test_project_model.py | 12 +-- 15 files changed, 358 insertions(+), 266 deletions(-) create mode 100644 samples/explore_favorites.py diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py new file mode 100644 index 000000000..243e91954 --- /dev/null +++ b/samples/explore_favorites.py @@ -0,0 +1,85 @@ +# This script demonstrates how to get all favorites, or add/delete a favorite. + +import argparse +import logging +import tableauserverclient as TSC +from tableauserverclient import Resource + + +def main(): + parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + args = parser.parse_args() + + # 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.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + print(server) + my_workbook = None + my_view = None + my_datasource = None + + # get all favorites on site for the logged on user + user: TSC.UserItem = TSC.UserItem() + user.id = server.user_id + print("Favorites for user: {}".format(user.id)) + server.favorites.get(user) + print(user.favorites) + + # get list of workbooks + all_workbook_items, pagination_item = server.workbooks.get() + if all_workbook_items is not None and len(all_workbook_items) > 0: + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + print( + "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( + my_workbook.name, my_workbook.id + ) + ) + views = server.workbooks.populate_views(my_workbook) + if views is not None and len(views) > 0: + my_view = views[0] + server.favorites.add_favorite_view(user, my_view) + print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + + all_datasource_items, pagination_item = server.datasources.get() + if all_datasource_items: + my_datasource = all_datasource_items[0] + server.favorites.add_favorite_datasource(user, my_datasource) + print( + "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) + ) + + server.favorites.delete_favorite_workbook(user, my_workbook) + print( + "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) + ) + + server.favorites.delete_favorite_view(user, my_view) + print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + + server.favorites.delete_favorite_datasource(user, my_datasource) + print( + "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) + ) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index dbaa0ff91..7fcc31ebf 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -305,50 +305,16 @@ def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) for datasource_xml in all_datasource_xml: - ( - ask_data_enablement, - certified, - certification_note, - content_url, - created_at, - datasource_type, - description, - encrypt_extracts, - has_extracts, - id_, - name, - owner_id, - project_id, - project_name, - tags, - updated_at, - use_remote_query_agent, - webpage_url, - ) = cls._parse_element(datasource_xml, ns) - datasource_item = cls(project_id) - datasource_item._set_values( - ask_data_enablement, - certified, - certification_note, - content_url, - created_at, - datasource_type, - description, - encrypt_extracts, - has_extracts, - id_, - name, - owner_id, - None, - project_name, - tags, - updated_at, - use_remote_query_agent, - webpage_url, - ) + datasource_item = cls.from_xml(datasource_xml, ns) all_datasource_items.append(datasource_item) return all_datasource_items + @classmethod + def from_xml(cls, datasource_xml, ns): + datasource_item = cls() + datasource_item._set_values(*cls._parse_element(datasource_xml, ns)) + return datasource_item + @staticmethod def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: id_ = datasource_xml.get("id", None) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index afa769fd9..f075d1fc3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,74 +1,90 @@ import logging from defusedxml.ElementTree import fromstring +from .tableau_types import TableauItem from .datasource_item import DatasourceItem from .flow_item import FlowItem from .project_item import ProjectItem +from .metric_item import MetricItem from .view_item import ViewItem from .workbook_item import WorkbookItem +from typing import Dict, List logger = logging.getLogger("tableau.models.favorites_item") -from typing import Dict, List, Union FavoriteType = Dict[ str, - List[ - Union[ - DatasourceItem, - ProjectItem, - FlowItem, - ViewItem, - WorkbookItem, - ] - ], + List[TableauItem], ] class FavoriteItem: - class Type: - Workbook: str = "workbook" - Datasource: str = "datasource" - View: str = "view" - Project: str = "project" - Flow: str = "flow" - @classmethod def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], "projects": [], + "metrics": [], "views": [], "workbooks": [], } - parsed_response = fromstring(xml) - for workbook in parsed_response.findall(".//t:favorite/t:workbook", namespace): - fav_workbook = WorkbookItem("") - fav_workbook._set_values(*fav_workbook._parse_element(workbook, namespace)) - if fav_workbook: - favorites["workbooks"].append(fav_workbook) - for view in parsed_response.findall(".//t:favorite[t:view]", namespace): - fav_views = ViewItem.from_xml_element(view, namespace) - if fav_views: - for fav_view in fav_views: - favorites["views"].append(fav_view) - for datasource in parsed_response.findall(".//t:favorite/t:datasource", namespace): - fav_datasource = DatasourceItem("") - fav_datasource._set_values(*fav_datasource._parse_element(datasource, namespace)) + + datasources_xml = parsed_response.findall(".//t:favorite/t:datasource", namespace) + flows_xml = parsed_response.findall(".//t:favorite/t:flow", namespace) + metrics_xml = parsed_response.findall(".//t:favorite/t:metric", namespace) + projects_xml = parsed_response.findall(".//t:favorite/t:project", namespace) + views_xml = parsed_response.findall(".//t:favorite/t:view", namespace) + workbooks_xml = parsed_response.findall(".//t:favorite/t:workbook", namespace) + + logger.debug( + "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}".format( + len(datasources_xml), + len(flows_xml), + len(metrics_xml), + len(projects_xml), + len(views_xml), + len(workbooks_xml), + ) + ) + for datasource in datasources_xml: + fav_datasource = DatasourceItem.from_xml(datasource, namespace) if fav_datasource: + logger.debug(fav_datasource) favorites["datasources"].append(fav_datasource) - for project in parsed_response.findall(".//t:favorite/t:project", namespace): - fav_project = ProjectItem("p") - fav_project._set_values(*fav_project._parse_element(project)) - if fav_project: - favorites["projects"].append(fav_project) - for flow in parsed_response.findall(".//t:favorite/t:flow", namespace): - fav_flow = FlowItem("flows") - fav_flow._set_values(*fav_flow._parse_element(flow, namespace)) + + for flow in flows_xml: + fav_flow = FlowItem.from_xml(flow, namespace) if fav_flow: + logger.debug(fav_flow) favorites["flows"].append(fav_flow) + for metric in metrics_xml: + fav_metric = MetricItem.from_xml(metric, namespace) + if fav_metric: + logger.debug(fav_metric) + favorites["metrics"].append(fav_metric) + + for project in projects_xml: + fav_project = ProjectItem.from_xml(project, namespace) + if fav_project: + logger.debug(fav_project) + favorites["projects"].append(fav_project) + + for view in views_xml: + fav_view = ViewItem.from_xml(view, namespace) + if fav_view: + logger.debug(fav_view) + favorites["views"].append(fav_view) + + for workbook in workbooks_xml: + fav_workbook = WorkbookItem.from_xml(workbook, namespace) + if fav_workbook: + logger.debug(fav_workbook) + favorites["workbooks"].append(fav_workbook) + + logger.debug(favorites) return favorites diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index f48910602..d543ad8eb 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -176,34 +176,39 @@ def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) for flow_xml in all_flow_xml: - ( - id_, - name, - description, - webpage_url, - created_at, - updated_at, - tags, - project_id, - project_name, - owner_id, - ) = cls._parse_element(flow_xml, ns) - flow_item = cls(project_id) - flow_item._set_values( - id_, - name, - description, - webpage_url, - created_at, - updated_at, - tags, - None, - project_name, - owner_id, - ) + flow_item = cls.from_xml(flow_xml, ns) all_flow_items.append(flow_item) return all_flow_items + @classmethod + def from_xml(cls, flow_xml, ns) -> "FlowItem": + ( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + project_id, + project_name, + owner_id, + ) = cls._parse_element(flow_xml, ns) + flow_item = cls(project_id) + flow_item._set_values( + id_, + name, + description, + webpage_url, + created_at, + updated_at, + tags, + None, + project_name, + owner_id, + ) + return flow_item + @staticmethod def _parse_element(flow_xml, ns): id_ = flow_xml.get("id", None) diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 4adc73fa8..e390d2c4d 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -5,6 +5,7 @@ from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime from .tag_item import TagItem +from .permissions_item import Permission class MetricItem(object): @@ -22,6 +23,7 @@ def __init__(self, name: Optional[str] = None): self._view_id: Optional[str] = None self._initial_tags: Set[str] = set() self.tags: Set[str] = set() + self._permissions: Optional[Permission] = None @property def id(self) -> Optional[str]: @@ -110,6 +112,9 @@ def view_id(self) -> Optional[str]: def view_id(self, value: Optional[str]) -> None: self._view_id = value + def _set_permissions(self, permissions): + self._permissions = permissions + def __repr__(self): return "".format(**vars(self)) @@ -123,36 +128,35 @@ def from_response( parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) for metric_xml in all_metric_xml: - metric_item = cls() - metric_item._id = metric_xml.get("id", None) - metric_item._name = metric_xml.get("name", None) - metric_item._description = metric_xml.get("description", None) - metric_item._webpage_url = metric_xml.get("webpageUrl", None) - metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) - metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) - metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) - for owner in metric_xml.findall(".//t:owner", namespaces=ns): - metric_item._owner_id = owner.get("id", None) - - for project in metric_xml.findall(".//t:project", namespaces=ns): - metric_item._project_id = project.get("id", None) - metric_item._project_name = project.get("name", None) - - for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): - metric_item._view_id = view.get("id", None) - - tags = set() - tags_elem = metric_xml.find(".//t:tags", namespaces=ns) - if tags_elem is not None: - all_tags = TagItem.from_xml_element(tags_elem, ns) - tags = all_tags - - metric_item.tags = tags - metric_item._initial_tags = tags - - all_metric_items.append(metric_item) + all_metric_items.append(cls.from_xml(metric_xml, ns)) return all_metric_items + @classmethod + def from_xml(cls, metric_xml, ns): + metric_item = cls() + metric_item._id = metric_xml.get("id", None) + metric_item._name = metric_xml.get("name", None) + metric_item._description = metric_xml.get("description", None) + metric_item._webpage_url = metric_xml.get("webpageUrl", None) + metric_item._created_at = parse_datetime(metric_xml.get("createdAt", None)) + metric_item._updated_at = parse_datetime(metric_xml.get("updatedAt", None)) + metric_item._suspended = string_to_bool(metric_xml.get("suspended", "")) + for owner in metric_xml.findall(".//t:owner", namespaces=ns): + metric_item._owner_id = owner.get("id", None) + for project in metric_xml.findall(".//t:project", namespaces=ns): + metric_item._project_id = project.get("id", None) + metric_item._project_name = project.get("name", None) + for view in metric_xml.findall(".//t:underlyingView", namespaces=ns): + metric_item._view_id = view.get("id", None) + tags = set() + tags_elem = metric_xml.find(".//t:tags", namespaces=ns) + if tags_elem is not None: + all_tags = TagItem.from_xml_element(tags_elem, ns) + tags = all_tags + metric_item.tags = tags + metric_item._initial_tags = tags + return metric_item + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 21358431c..393a7990f 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -21,7 +21,7 @@ def __repr__(self): def __init__( self, - name: str, + name: Optional[str] = None, description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, @@ -104,11 +104,10 @@ def id(self) -> Optional[str]: return self._id @property - def name(self) -> str: + def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value @@ -173,19 +172,16 @@ def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - ( - id, - name, - description, - content_permissions, - parent_id, - owner_id, - ) = cls._parse_element(project_xml) - project_item = cls(name) - project_item._set_values(id, name, description, content_permissions, parent_id, owner_id) + project_item = cls.from_xml(project_xml) all_project_items.append(project_item) return all_project_items + @classmethod + def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + project_item = cls() + project_item._set_values(*cls._parse_element(project_xml)) + return project_item + @staticmethod def _parse_element(project_xml): id = project_xml.get("id", None) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 9649c7ed9..33fe5eb0c 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -5,23 +5,25 @@ from .project_item import ProjectItem from .view_item import ViewItem from .workbook_item import WorkbookItem +from .metric_item import MetricItem class Resource: Database = "database" Datarole = "datarole" + Table = "table" Datasource = "datasource" Flow = "flow" Lens = "lens" Metric = "metric" Project = "project" - Table = "table" View = "view" Workbook = "workbook" # resource types that have permissions, can be renamed, etc -TableauItem = Union[DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem] +# todo: refactoring: should actually define TableauItem as an interface and let all these implement it +TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem] def plural_type(content_type: Resource) -> str: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5e3d18fa6..a12f4b557 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -45,6 +45,7 @@ class Roles: class Auth: OpenID = "OpenID" SAML = "SAML" + TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" def __init__( diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 51cceaa9f..ef1fb0e52 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,5 +1,6 @@ import copy from datetime import datetime +from requests import Response from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -140,7 +141,7 @@ def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> self._permissions = permissions @classmethod - def from_response(cls, resp, ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod @@ -148,39 +149,38 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: - view_item = cls() - usage_elem = view_xml.find(".//t:usage", namespaces=ns) - workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) - owner_elem = view_xml.find(".//t:owner", namespaces=ns) - project_elem = view_xml.find(".//t:project", namespaces=ns) - tags_elem = view_xml.find(".//t:tags", namespaces=ns) - view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) - view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) - view_item._id = view_xml.get("id", None) - view_item._name = view_xml.get("name", None) - view_item._content_url = view_xml.get("contentUrl", None) - view_item._sheet_type = view_xml.get("sheetType", None) - - if usage_elem is not None: - total_view = usage_elem.get("totalViewCount", None) - if total_view: - view_item._total_views = int(total_view) - - if owner_elem is not None: - view_item._owner_id = owner_elem.get("id", None) - - if project_elem is not None: - view_item._project_id = project_elem.get("id", None) - - if workbook_id: - view_item._workbook_id = workbook_id - elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) - - if tags_elem is not None: - tags = TagItem.from_xml_element(tags_elem, ns) - view_item.tags = tags - view_item._initial_tags = copy.copy(tags) - + view_item = cls.from_xml(view_xml, ns, workbook_id) all_view_items.append(view_item) return all_view_items + + @classmethod + def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": + view_item = cls() + usage_elem = view_xml.find(".//t:usage", namespaces=ns) + workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) + owner_elem = view_xml.find(".//t:owner", namespaces=ns) + project_elem = view_xml.find(".//t:project", namespaces=ns) + tags_elem = view_xml.find(".//t:tags", namespaces=ns) + view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) + view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) + view_item._id = view_xml.get("id", None) + view_item._name = view_xml.get("name", None) + view_item._content_url = view_xml.get("contentUrl", None) + view_item._sheet_type = view_xml.get("sheetType", None) + if usage_elem is not None: + total_view = usage_elem.get("totalViewCount", None) + if total_view: + view_item._total_views = int(total_view) + if owner_elem is not None: + view_item._owner_id = owner_elem.get("id", None) + if project_elem is not None: + view_item._project_id = project_elem.get("id", None) + if workbook_id: + view_item._workbook_id = workbook_id + elif workbook_elem is not None: + view_item._workbook_id = workbook_elem.get("id", None) + if tags_elem is not None: + tags = TagItem.from_xml_element(tags_elem, ns) + view_item.tags = tags + view_item._initial_tags = copy.copy(tags) + return view_item diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index debbf30b5..16e05498b 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -20,7 +20,7 @@ class WorkbookItem(object): - def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool = False) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None self._webpage_url = None @@ -38,7 +38,8 @@ def __init__(self, project_id: str, name: Optional[str] = None, show_tabs: bool self.name = name self._description = None self.owner_id: Optional[str] = None - self.project_id = project_id + # workaround for Personal Space workbooks without a project + self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs self.hidden_views: Optional[List[str]] = None self.tags: Set[str] = set() @@ -293,49 +294,16 @@ def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) for workbook_xml in all_workbook_xml: - ( - id, - name, - content_url, - webpage_url, - created_at, - description, - updated_at, - size, - show_tabs, - project_id, - project_name, - owner_id, - tags, - views, - data_acceleration_config, - ) = cls._parse_element(workbook_xml, ns) - - # workaround for Personal Space workbooks which won't have a project - if not project_id: - project_id = uuid.uuid4() - - workbook_item = cls(project_id) - workbook_item._set_values( - id, - name, - content_url, - webpage_url, - created_at, - description, - updated_at, - size, - show_tabs, - None, - project_name, - owner_id, - tags, - views, - data_acceleration_config, - ) + workbook_item = cls.from_xml(workbook_xml, ns) all_workbook_items.append(workbook_item) return all_workbook_items + @classmethod + def from_xml(cls, workbook_xml, ns): + workbook_item = cls() + workbook_item._set_values(*cls._parse_element(workbook_xml, ns)) + return workbook_item + @staticmethod def _parse_element(workbook_xml, ns): id = workbook_xml.get("id", None) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5105b3bf4..dcddca259 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,14 +1,22 @@ import logging from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem - -from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem - from ..request_options import RequestOptions +from requests import Response + +from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.models import ( + DatasourceItem, + FavoriteItem, + FlowItem, + MetricItem, + ProjectItem, + UserItem, + ViewItem, + WorkbookItem, + TableauItem, +) + +from typing import Optional logger = logging.getLogger("tableau.endpoint.favorites") @@ -20,74 +28,112 @@ def baseurl(self) -> str: # Gets all favorites @api(version="2.5") - def get(self, user_item: "UserItem", req_options: Optional["RequestOptions"] = None) -> None: + def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: logger.info("Querying all favorites for user {0}".format(user_item.name)) url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) + # ---------add to favorites + + @api(version="3.15") + def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + return server_response + @api(version="2.0") - def add_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: + def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") - def add_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: + def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") - def add_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: + def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") - def add_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: + def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="3.3") - def add_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: + def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + @api(version="3.3") + def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + url = "{0}/{1}".format(self.baseurl, user_item.id) + add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) + server_response = self.put_request(url, add_req) + logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + + # ------- delete from favorites + # Response: + """ + + + + + + """ + + @api(version="3.15") + def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: + url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) + logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + self.delete_request(url) + @api(version="2.0") - def delete_favorite_workbook(self, user_item: "UserItem", workbook_item: "WorkbookItem") -> None: + def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") - def delete_favorite_view(self, user_item: "UserItem", view_item: "ViewItem") -> None: + def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(view_item.id, user_item.id)) + logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") - def delete_favorite_datasource(self, user_item: "UserItem", datasource_item: "DatasourceItem") -> None: + def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") - def delete_favorite_project(self, user_item: "UserItem", project_item: "ProjectItem") -> None: + def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(project_item.id, user_item.id)) + logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) @api(version="3.3") - def delete_favorite_flow(self, user_item: "UserItem", flow_item: "FlowItem") -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: + url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + self.delete_request(url) + + @api(version="3.15") + def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) + logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 050874c91..4140794b4 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -261,12 +261,16 @@ def update_req(self, dqw_item): class FavoriteRequest(object): - def _add_to_req(self, id_: str, target_type: str, label: str) -> bytes: + def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ """ + if id_ is None: + raise ValueError("Cannot add item as favorite without ID") + if label is None: + label = target_type xml_request = ET.Element("tsRequest") favorite_element = ET.SubElement(xml_request, "favorite") target = ET.SubElement(favorite_element, target_type) @@ -280,35 +284,35 @@ def add_datasource_req(self, id_: Optional[str], name: Optional[str]) -> bytes: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Datasource, name) + return self.add_request(id_, Resource.Datasource, name) def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Flow, name) + return self.add_request(id_, Resource.Flow, name) def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Project, name) + return self.add_request(id_, Resource.Project, name) def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.View, name) + return self.add_request(id_, Resource.View, name) def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") - return self._add_to_req(id_, FavoriteItem.Type.Workbook, name) + return self.add_request(id_, Resource.Workbook, name) class FileuploadRequest(object): @@ -485,7 +489,8 @@ def update_req(self, project_item: "ProjectItem") -> bytes: def create_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") - project_element.attrib["name"] = project_item.name + if project_item.name: + project_element.attrib["name"] = project_item.name if project_item.description: project_element.attrib["description"] = project_item.description if project_item.content_permissions: diff --git a/test/test_favorites.py b/test/test_favorites.py index 9dcc3bb38..6f0be3b3c 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -37,6 +37,8 @@ def test_get(self) -> None: self.assertEqual(len(self.user.favorites["datasources"]), 1) workbook = self.user.favorites["workbooks"][0] + print("favorited: ") + print(workbook) view = self.user.favorites["views"][0] datasource = self.user.favorites["datasources"][0] project = self.user.favorites["projects"][0] diff --git a/test/test_project.py b/test/test_project.py index 3c75a0d3c..33d9c3865 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -155,7 +155,7 @@ def test_create(self) -> None: self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", new_project.parent_id) def test_create_missing_name(self) -> None: - self.assertRaises(ValueError, TSC.ProjectItem, "") + TSC.ProjectItem() def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: diff --git a/test/test_project_model.py b/test/test_project_model.py index a8b96dc4f..6ddaf8607 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -4,15 +4,11 @@ class ProjectModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.ProjectItem, None) - self.assertRaises(ValueError, TSC.ProjectItem, "") + def test_nullable_name(self): + TSC.ProjectItem(None) + TSC.ProjectItem("") project = TSC.ProjectItem("proj") - with self.assertRaises(ValueError): - project.name = None - - with self.assertRaises(ValueError): - project.name = "" + project.name = None def test_invalid_content_permissions(self): project = TSC.ProjectItem("proj") From 830a3a61c8a85b9c0d8ed58a11b6e27ea98e2cf4 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 12 Apr 2023 18:22:33 -0700 Subject: [PATCH 303/567] Jac/small things (#1215) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 --- tableauserverclient/models/datasource_item.py | 7 +++---- .../server/endpoint/workbooks_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 19 ++++++++++++++----- tableauserverclient/server/request_options.py | 3 ++- test/test_datasource_model.py | 8 +++----- test/test_view.py | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index b5568a778..dbaa0ff91 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -32,7 +32,7 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: self._ask_data_enablement = None self._certified = None self._certification_note = None @@ -135,12 +135,11 @@ def id(self) -> Optional[str]: return self._id @property - def project_id(self) -> str: + def project_id(self) -> Optional[str]: return self._project_id @project_id.setter - @property_not_nullable - def project_id(self, value: str): + def project_id(self, value: Optional[str]): self._project_id = value @property diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dc4adafaa..a73b0f0d5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -310,6 +310,7 @@ def publish( as_job: bool = False, hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, + parameters=None, ): if connection_credentials is not None: import warnings @@ -413,7 +414,7 @@ def publish( # Send the publishing request to server try: - server_response = self.post_request(url, xml_request, content_type) + server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: if err.code == 504 and not as_job: err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b19c3cc56..050874c91 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server +# this file could be largely replaced if we were willing to import the huge file from generateDS + def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() @@ -146,10 +148,11 @@ def update_req(self, database_item): class DatasourceRequest(object): - def _generate_xml(self, datasource_item, connection_credentials=None, connections=None): + def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") - datasource_element.attrib["name"] = datasource_item.name + if datasource_item.name: + datasource_element.attrib["name"] = datasource_item.name if datasource_item.description: datasource_element.attrib["description"] = str(datasource_item.description) if datasource_item.use_remote_query_agent is not None: @@ -157,10 +160,16 @@ def _generate_xml(self, datasource_item, connection_credentials=None, connection if datasource_item.ask_data_enablement: ask_data_element = ET.SubElement(datasource_element, "askData") - ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement + ask_data_element.attrib["enablement"] = datasource_item.ask_data_enablement.__str__() - project_element = ET.SubElement(datasource_element, "project") - project_element.attrib["id"] = datasource_item.project_id + if datasource_item.certified: + datasource_element.attrib["isCertified"] = datasource_item.certified.__str__() + if datasource_item.certification_note: + datasource_element.attrib["certificationNote"] = datasource_item.certification_note + + if datasource_item.project_id: + project_element = ET.SubElement(datasource_element, "project") + project_element.attrib["id"] = datasource_item.project_id if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fa0a2d68a..1ee18e9df 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: class Field: Args = "args" CompletedAt = "completedAt" + ContentUrl = "contentUrl" CreatedAt = "createdAt" DomainName = "domainName" DomainNickname = "domainNickname" @@ -147,7 +148,7 @@ def get_query_params(self): return params -class ExcelRequestOptions(RequestOptionsBase): +class ExcelRequestOptions(_FilterOptionsBase): def __init__(self, maxage: int = -1) -> None: super().__init__() self.max_age = maxage diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 2360574ec..655284194 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -3,11 +3,9 @@ class DatasourceModelTests(unittest.TestCase): - def test_invalid_project_id(self): - self.assertRaises(ValueError, TSC.DatasourceItem, None) - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.project_id = None + def test_nullable_project_id(self): + datasource = TSC.DatasourceItem(name="10") + self.assertEqual(datasource.project_id, None) def test_require_boolean_flag_bridge_fail(self): datasource = TSC.DatasourceItem("10") diff --git a/test/test_view.py b/test/test_view.py index f5d3db47b..1459150bb 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -299,3 +299,19 @@ def test_populate_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_filter_excel(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_EXCEL, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + self.server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + self.assertEqual(response, excel_file) From a29d6ebb6e6f95600567f72129b18be83c82f314 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 Apr 2023 12:03:04 -0700 Subject: [PATCH 304/567] update datasource to use bridge (#1224) Update request_factory.py --- tableauserverclient/server/request_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 050874c91..91a120512 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -197,6 +197,8 @@ def update_req(self, datasource_item): if datasource_item.owner_id: owner_element = ET.SubElement(datasource_element, "owner") owner_element.attrib["id"] = datasource_item.owner_id + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() From beda2d88057c3b1da3f3aba536e25f28aa73c4ca Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 305/567] fix imports --- .../server/endpoint/favorites_endpoint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index d33452b30..ac9e4b185 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,25 +1,22 @@ -import logging - from .endpoint import Endpoint, api from requests import Response -from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, FavoriteItem, FlowItem, MetricItem, ProjectItem, + Resource, + TableauItem, UserItem, ViewItem, WorkbookItem, - TableauItem, ) - +from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional -from tableauserverclient.helpers.logging import logger - class Favorites(Endpoint): @property From 307d8a20a30f32c1ce615cca7c6a78b9b9bff081 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 24 Apr 2023 13:08:23 -0700 Subject: [PATCH 306/567] 0.26 logging updates, long running uploads (#1222) TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled (#1217) Run long requests on second thread (#1212) https://github.com/tableau/server-client-python/issues/1210 https://github.com/tableau/server-client-python/issues/1087 https://github.com/tableau/server-client-python/issues/1058 https://github.com/tableau/server-client-python/issues/456 https://github.com/tableau/server-client-python/issues/1209 update datasource to use bridge (#1224) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> --- .gitignore | 1 + samples/add_default_permission.py | 10 +- samples/create_group.py | 10 +- samples/create_project.py | 10 +- samples/create_schedules.py | 10 +- samples/explore_datasource.py | 10 +- samples/explore_site.py | 10 +- samples/explore_webhooks.py | 10 +- samples/explore_workbook.py | 10 +- samples/export.py | 10 +- samples/extracts.py | 10 +- samples/filter_sort_groups.py | 10 +- samples/filter_sort_projects.py | 10 +- samples/initialize_server.py | 14 +-- samples/kill_all_jobs.py | 10 +- samples/list.py | 10 +- samples/login.py | 21 +++- samples/metadata_query.py | 10 +- samples/move_workbook_projects.py | 14 +-- samples/move_workbook_sites.py | 14 +-- samples/pagination_sample.py | 10 +- samples/publish_datasource.py | 40 +++++-- samples/publish_workbook.py | 12 +- samples/query_permissions.py | 10 +- samples/refresh.py | 10 +- samples/refresh_tasks.py | 10 +- samples/set_refresh_schedule.py | 10 +- samples/smoke_test.py | 10 +- samples/update_connection.py | 10 +- samples/update_datasource_data.py | 10 +- tableauserverclient/config.py | 13 +++ tableauserverclient/datetime_helpers.py | 4 + tableauserverclient/helpers/logging.py | 6 + tableauserverclient/models/connection_item.py | 2 +- tableauserverclient/models/favorites_item.py | 4 +- tableauserverclient/models/fileupload_item.py | 4 +- .../models/permissions_item.py | 2 +- .../models/server_info_item.py | 2 +- tableauserverclient/server/__init__.py | 4 +- .../server/endpoint/__init__.py | 6 +- .../server/endpoint/auth_endpoint.py | 2 +- .../server/endpoint/custom_views_endpoint.py | 2 +- .../data_acceleration_report_endpoint.py | 2 +- .../server/endpoint/data_alert_endpoint.py | 2 +- .../server/endpoint/databases_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 36 +++--- .../endpoint/default_permissions_endpoint.py | 2 +- .../server/endpoint/dqw_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 109 ++++++++++++++++-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 11 +- .../server/endpoint/fileuploads_endpoint.py | 21 ++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 10 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metadata_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/permissions_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 6 +- .../server/endpoint/schedules_endpoint.py | 3 +- .../server/endpoint/server_info_endpoint.py | 6 +- .../server/endpoint/sites_endpoint.py | 2 +- .../server/endpoint/subscriptions_endpoint.py | 2 +- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/tasks_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/webhooks_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 3 +- tableauserverclient/server/exceptions.py | 9 +- tableauserverclient/server/request_factory.py | 2 + tableauserverclient/server/request_options.py | 2 +- tableauserverclient/server/server.py | 25 ++-- test/test_auth.py | 6 +- test/test_datasource.py | 6 +- test/test_endpoint.py | 25 +++- test/test_fileuploads.py | 4 +- test/test_request_option.py | 6 +- test/test_webhook.py | 3 +- 81 files changed, 401 insertions(+), 333 deletions(-) create mode 100644 tableauserverclient/config.py create mode 100644 tableauserverclient/helpers/logging.py diff --git a/.gitignore b/.gitignore index d8caf99a9..f0226c065 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ celerybeat-schedule # dotenv .env +env.py # virtualenv venv/ diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 8a87c1fd6..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Add workbook default permissions for a given project.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_group.py b/samples/create_group.py index 2229f7f26..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates a sample user group.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_project.py b/samples/create_project.py index 8b2ec3354..611dbe366 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -28,14 +28,10 @@ def create_project(server, project_item, samples=False): def main(): parser = argparse.ArgumentParser(description="Create new projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/create_schedules.py b/samples/create_schedules.py index f193352de..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index aafbe167c..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -18,14 +18,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_site.py b/samples/explore_site.py index a181abfec..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore site updates by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 47e59ac06..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f242ace70..c61b9b637 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/export.py b/samples/export.py index 4c26770b9..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/extracts.py b/samples/extracts.py index c77da89d0..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -19,14 +19,10 @@ def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", help="site name") - parser.add_argument( - "--token-name", "-tn", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-tv", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 984d8d344..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -26,14 +26,10 @@ def create_example_group(group_name="Example Group", server=None): def main(): parser = argparse.ArgumentParser(description="Filter and sort groups.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 608f472ba..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -29,14 +29,10 @@ def create_example_project( def main(): parser = argparse.ArgumentParser(description="Filter and sort projects.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/initialize_server.py b/samples/initialize_server.py index e7ed0139f..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Initialize a server with content.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -29,8 +25,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--datasources-folder", "-df", required=True, help="folder containing datasources") - parser.add_argument("--workbooks-folder", "-wf", required=True, help="folder containing workbooks") + parser.add_argument("--datasources-folder", "-df", help="folder containing datasources") + parser.add_argument("--workbooks-folder", "-wf", help="folder containing workbooks") parser.add_argument("--project", required=False, default="Default", help="project to use") args = parser.parse_args() diff --git a/samples/kill_all_jobs.py b/samples/kill_all_jobs.py index 1a833f938..bfebb49b8 100644 --- a/samples/kill_all_jobs.py +++ b/samples/kill_all_jobs.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Cancel all of the running background jobs.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/list.py b/samples/list.py index b5cdb38a5..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="List out the names and LUIDs for different resource types.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/login.py b/samples/login.py index f3e9d77dc..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -9,6 +9,7 @@ import logging import tableauserverclient as TSC +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -18,10 +19,15 @@ def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") sample_define_common_options(parser) args = parser.parse_args() - - # Set logging level based on user input, or error by default. - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging_level = "debug" server = sample_connect_to_server(args) print(server.server_info.get()) @@ -30,9 +36,9 @@ def set_up_and_log_in(): def sample_define_common_options(parser): # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=True) + auth = parser.add_mutually_exclusive_group(required=False) auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") auth.add_argument("--username", "-u", help="username to sign into the server") @@ -73,6 +79,9 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) print("Logged in successfully") return server diff --git a/samples/metadata_query.py b/samples/metadata_query.py index 26f8f94fa..7524453c2 100644 --- a/samples/metadata_query.py +++ b/samples/metadata_query.py @@ -14,14 +14,10 @@ def main(): parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/move_workbook_projects.py b/samples/move_workbook_projects.py index be49ec23b..392dc0ff8 100644 --- a/samples/move_workbook_projects.py +++ b/samples/move_workbook_projects.py @@ -17,14 +17,10 @@ def main(): parser = argparse.ArgumentParser(description="Move one workbook from the default project to another.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -33,8 +29,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-project", "-d", required=True, help="name of project to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-project", "-d", help="name of project to move workbook into") args = parser.parse_args() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 3feb62be2..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -22,14 +22,10 @@ def main(): "the default project of another site." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -38,8 +34,8 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--workbook-name", "-w", required=True, help="name of workbook to move") - parser.add_argument("--destination-site", "-d", required=True, help="name of site to move workbook into") + parser.add_argument("--workbook-name", "-w", help="name of workbook to move") + parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") args = parser.parse_args() diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index b55fef320..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -20,14 +20,10 @@ def main(): parser = argparse.ArgumentParser(description="Demonstrate pagination on the list of workbooks on the server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-n", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 8d9e59ea2..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -23,18 +23,17 @@ import tableauserverclient as TSC +import env +import tableauserverclient.datetime_helpers + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -43,7 +42,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="filepath to the datasource to publish") + parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") parser.add_argument("--async", "-a", help="Publishing asynchronously", dest="async_", action="store_true") parser.add_argument("--conn-username", help="connection username") @@ -52,14 +51,27 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() + if not args.server: + args.server = env.server + if not args.site: + args.site = env.site + if not args.token_name: + args.token_name = env.token_name + if not args.token_value: + args.token_value = env.token_value + args.logging = "debug" + args.file = "C:/dev/tab-samples/5M.tdsx" + args.async_ = True # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) + + _logger = logging.getLogger(__name__) + _logger.setLevel(logging.DEBUG) + _logger.addHandler(logging.StreamHandler()) # Sign in to server tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) @@ -94,6 +106,7 @@ def main(): # Publish datasource if args.async_: + print("Publish as a job") # Async publishing, returns a job_item new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True @@ -104,7 +117,12 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) - print("Datasource published. Datasource ID: {0}".format(new_datasource.id)) + print( + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) + ) + print("\t\tClosing connection") if __name__ == "__main__": diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index f0edc380c..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -24,14 +24,10 @@ def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", @@ -40,7 +36,7 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--file", "-f", required=True, help="local filepath of the workbook to publish") + parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 7106da934..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -15,14 +15,10 @@ def main(): parser = argparse.ArgumentParser(description="Query permissions of a given resource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/refresh.py b/samples/refresh.py index f90441224..d3e49ed24 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Trigger a refresh task on a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 2bfc85621..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -30,14 +30,10 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 9b3dbc236..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -15,14 +15,10 @@ def usage(args): parser = argparse.ArgumentParser(description="Set refresh schedule for a workbook or datasource.") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/smoke_test.py b/samples/smoke_test.py index f2dad1048..b23eacdb8 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,8 +1,16 @@ # This sample verifies that tableau server client is installed # and you can run it. It also shows the version of the client. +import logging import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + server = TSC.Server("Fake-Server-Url", use_server_version=False) print("Client details:") -print(TSC.server.endpoint.Endpoint._make_common_headers("fake-token", "any-content")) +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/samples/update_connection.py b/samples/update_connection.py index e27b4477f..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -13,14 +13,10 @@ def main(): parser = argparse.ArgumentParser(description="Update a connection on a datasource or workbook to embed credentials") # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index 41f42ee74..f6bc92022 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -25,14 +25,10 @@ def main(): description="Delete the `Europe` region from a published `World Indicators` datasource." ) # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", required=True, help="server address") + parser.add_argument("--server", "-s", help="server address") parser.add_argument("--site", "-S", help="site name") - parser.add_argument( - "--token-name", "-p", required=True, help="name of the personal access token used to sign into the server" - ) - parser.add_argument( - "--token-value", "-v", required=True, help="value of the personal access token used to sign into the server" - ) + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") parser.add_argument( "--logging-level", "-l", diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py new file mode 100644 index 000000000..67a77f479 --- /dev/null +++ b/tableauserverclient/config.py @@ -0,0 +1,13 @@ +# TODO: check for env variables, else set default values + +ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] + +BYTES_PER_MB = 1024 * 1024 + +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks +CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 + +DELAY_SLEEP_SECONDS = 10 + +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 0d968428d..00f62faf8 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,6 +5,10 @@ HOUR = datetime.timedelta(hours=1) +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py new file mode 100644 index 000000000..414d85786 --- /dev/null +++ b/tableauserverclient/helpers/logging.py @@ -0,0 +1,6 @@ +import logging + +# TODO change: this defaults to logging *everything* to stdout +logger = logging.getLogger("TSC") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 4ed06b831..29ffd2700 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -5,6 +5,7 @@ from .connection_credentials import ConnectionCredentials from .property_decorators import property_is_boolean +from tableauserverclient.helpers.logging import logger class ConnectionItem(object): @@ -46,7 +47,6 @@ def query_tagging(self) -> Optional[bool]: def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: - logger = logging.getLogger("tableauserverclient.models.connection_item") logger.debug( "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index f075d1fc3..987623404 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -11,8 +11,8 @@ from .workbook_item import WorkbookItem from typing import Dict, List -logger = logging.getLogger("tableau.models.favorites_item") - +from tableauserverclient.helpers.logging import logger +from typing import Dict, List, Union FavoriteType = Dict[ str, diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index 7848b94cf..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -11,8 +11,8 @@ def upload_session_id(self): return self._upload_session_id @property - def file_size(self): - return self._file_size + def file_size(self) -> int: + return int(self._file_size) @classmethod def from_response(cls, resp, ns): diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3bdc63092..1602b077f 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -9,7 +9,7 @@ from .reference_item import ResourceReference from .user_item import UserItem -logger = logging.getLogger("tableau.models.permissions_item") +from tableauserverclient.helpers.logging import logger class Permission: diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5f9395880..b180665dd 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -3,6 +3,7 @@ import xml from defusedxml.ElementTree import fromstring +from tableauserverclient.helpers.logging import logger class ServerInfoItem(object): @@ -36,7 +37,6 @@ def rest_api_version(self): @classmethod def from_response(cls, resp, ns): - logger = logging.getLogger("TSC.ServerInfo") try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index bcea2604e..5abe19446 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -10,9 +10,7 @@ from .filter import Filter from .sort import Sort -from ..models import * from .endpoint import * from .server import Server from .pager import Pager -from .exceptions import NotSignedInError -from ..helpers import * +from .endpoint.exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e8e1bc0f9..c018d8334 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -5,11 +5,7 @@ from .databases_endpoint import Databases from .datasources_endpoint import Datasources from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ( - ServerResponseError, - MissingRequiredFieldError, - ServerInfoEndpointNotFoundError, -) +from .exceptions import ServerResponseError, MissingRequiredFieldError from .favorites_endpoint import Favorites from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 68d75eaa8..6f1ddc35e 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -6,7 +6,7 @@ from .exceptions import ServerResponseError from ..request_factory import RequestFactory -logger = logging.getLogger("tableau.endpoint.auth") +from tableauserverclient.helpers.logging import logger class Auth(Endpoint): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 778cafecc..119580609 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import CustomViewItem, PaginationItem from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions -logger = logging.getLogger("tableau.endpoint.custom_views") +from tableauserverclient.helpers.logging import logger """ Get a list of custom views on a site diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 28e5495c5..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -5,7 +5,7 @@ from .permissions_endpoint import _PermissionsEndpoint from tableauserverclient.models import DataAccelerationReportItem -logger = logging.getLogger("tableau.endpoint.data_acceleration_report") +from tableauserverclient.helpers.logging import logger class DataAccelerationReport(Endpoint): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index 5af4e0464..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DataAlertItem, PaginationItem, UserItem -logger = logging.getLogger("tableau.endpoint.dataAlerts") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2522ef53e..125996277 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource -logger = logging.getLogger("tableau.endpoint.databases") +from tableauserverclient.helpers.logging import logger class Databases(Endpoint): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0c5b8ba61..c60f8f919 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,7 +1,6 @@ import cgi import copy import json -import logging import io import os @@ -20,13 +19,14 @@ from .permissions_endpoint import _PermissionsEndpoint from .resource_tagger import _ResourceTagger -from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( - to_filename, make_download_path, get_file_type, get_file_object_size, + to_filename, ) +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( ConnectionCredentials, ConnectionItem, @@ -35,6 +35,7 @@ RevisionItem, PaginationItem, ) +from tableauserverclient.server import RequestFactory, RequestOptions io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) @@ -44,13 +45,6 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB - -ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] - -logger = logging.getLogger("tableau.endpoint.datasources") - FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] @@ -162,12 +156,20 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: # Update datasource connections @api(version="2.3") - def update_connection(self, datasource_item: DatasourceItem, connection_item: ConnectionItem) -> ConnectionItem: + def update_connection( + self, datasource_item: DatasourceItem, connection_item: ConnectionItem + ) -> Optional[ConnectionItem]: url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) - connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + if not connections: + return None + + if len(connections) > 1: + logger.debug("Multiple connections returned ({0})".format(len(connections))) + connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] logger.info( "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) @@ -220,7 +222,7 @@ def publish( filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -261,8 +263,12 @@ def publish( url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (datasource over 64MB)".format(filename)) + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + logger.info( + "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( + filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + ) + ) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index b0d16efaf..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -10,7 +10,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type BaseItem = Union[DatabaseItem, ProjectItem] diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 96cb7c5f9..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import DQWItem -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger class _DataQualityWarningEndpoint(Endpoint): diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9c933c9dd..c11a3fb27 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,24 +1,31 @@ +from threading import Thread +from time import sleep +from tableauserverclient import datetime_helpers as datetime + import requests -import logging from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union from .exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, - EndpointUnavailableError, + NotSignedInError, ) +from ..exceptions import EndpointUnavailableError + from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions +from tableauserverclient.helpers.logging import logger +from tableauserverclient.config import DELAY_SLEEP_SECONDS + if TYPE_CHECKING: from ..server import Server from requests import Response -logger = logging.getLogger("tableau.endpoint") Success_codes = [200, 201, 202, 204] @@ -34,6 +41,8 @@ class Endpoint(object): def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv + async_response = None + @staticmethod def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} @@ -53,6 +62,8 @@ def set_parameters(http_options, auth_token, content, content_type, parameters) @staticmethod def set_user_agent(parameters): + if "headers" not in parameters: + parameters["headers"] = {} if USER_AGENT_HEADER not in parameters["headers"]: if USER_AGENT_HEADER in parameters: parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] @@ -65,6 +76,59 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters + def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + self.async_response = None + response = None + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + try: + response = method(url, **parameters) + self.async_response = response + logger.debug("[{}] Call finished".format(datetime.timestamp())) + except Exception as e: + logger.debug("Error making request to server: {}".format(e)) + self.async_response = e + finally: + if response and not self.async_response: + logger.debug("Request response not saved") + return None + logger.debug("[{}] Request complete".format(datetime.timestamp())) + return self.async_response + + def send_request_while_show_progress_threaded( + self, method, url, parameters={}, request_timeout=0 + ) -> Optional["Response"]: + try: + request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) + request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms + request_thread.start() + except Exception as e: + logger.debug("Error starting server request on separate thread: {}".format(e)) + return None + seconds = 0 + minutes = 0 + sleep(1) + if self.async_response != -1: + # a quick return for any immediate responses + return self.async_response + while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + self.log_wait_time_then_sleep(minutes, seconds, url) + seconds = seconds + DELAY_SLEEP_SECONDS + if seconds >= 60: + seconds = 0 + minutes = minutes + 1 + return self.async_response + + def log_wait_time_then_sleep(self, minutes, seconds, url): + logger.debug("{} Waiting....".format(datetime.timestamp())) + if seconds >= 60: # detailed log message ~every minute + if minutes % 5 == 0: + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + sleep(DELAY_SLEEP_SECONDS) + def _make_request( self, method: Callable[..., "Response"], @@ -80,36 +144,59 @@ def _make_request( logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: - redacted = helpers.strings.redact_xml(content[:1000]) + redacted = helpers.strings.redact_xml(content[:200]) + # this needs to be under a trace or something, it's a LOT # logger.debug("request content: {}".format(redacted)) - server_response = method(url, **parameters) + # a request can, for stuff like publishing, spin for ages waiting for a response. + # we need some user-facing activity so they know it's not dead. + request_timeout = self.parent_srv.http_options.get("timeout") or 0 + server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + method, url, parameters, request_timeout + ) + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + # is this blocking retry really necessary? I guess if it was just the threading messing it up? + if server_response is None: + logger.debug(server_response) + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + server_response = self._blocking_request(method, url, parameters) + if server_response is None: + logger.debug("[{}] Request failed".format(datetime.timestamp())) + raise RuntimeError self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - # logger.debug("Server response from {0}:\n\t{1}".format(url, loggable_response)) + logger.debug("Server response from {0}".format(url)) + # logger.debug("\n\t{1}".format(loggable_response)) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) return server_response - def _check_status(self, server_response, url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: Optional[str] = None): + logger.debug("Response status: {}".format(server_response)) + if not hasattr(server_response, "status_code"): + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: + if server_response.status_code == 401: + # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry + raise NotSignedInError(server_response.content, url) + raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: # This will happen if we get a non-success HTTP code that doesn't return an xml error object - # e.g metadata endpoints, 503 pages, totally different servers + # e.g. metadata endpoints, 503 pages, totally different servers # we convert this to a better exception and pass through the raw response body raise NonXMLResponseError(server_response.content) except Exception: # anything else re-raise here raise - def log_response_safely(self, server_response: requests.Response) -> str: + def log_response_safely(self, server_response: "Response") -> str: # Checking the content type header prevents eager evaluation of streaming requests. content_type = server_response.headers.get("Content-Type") @@ -117,7 +204,7 @@ def log_response_safely(self, server_response: requests.Response) -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type {}".format(content_type) + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index d7b1d5ad2..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -47,11 +47,7 @@ class MissingRequiredFieldError(TableauError): pass -class ServerInfoEndpointNotFoundError(TableauError): - pass - - -class EndpointUnavailableError(TableauError): +class NotSignedInError(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index dcddca259..ac9e4b185 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,25 +1,22 @@ -import logging - from .endpoint import Endpoint, api from requests import Response -from tableauserverclient.server import RequestFactory, RequestOptions, Resource +from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, FavoriteItem, FlowItem, MetricItem, ProjectItem, + Resource, + TableauItem, UserItem, ViewItem, WorkbookItem, - TableauItem, ) - +from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional -logger = logging.getLogger("tableau.endpoint.favorites") - class Favorites(Endpoint): @property diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 9a8e9560d..a0e29e508 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -1,13 +1,10 @@ -import logging - from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FileuploadItem +from tableauserverclient import datetime_helpers as datetime +from tableauserverclient.helpers.logging import logger -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE = 1024 * 1024 * 5 # 5MB - -logger = logging.getLogger("tableau.endpoint.fileuploads") +from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.models import FileuploadItem +from tableauserverclient.server import RequestFactory class Fileuploads(Endpoint): @@ -44,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE) + chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content @@ -55,8 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info("\tPublished {0}MB".format(fileupload_item.file_size)) + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3bca93a7f..63b32e006 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.flowruns") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 4d97110c4..ba8a152d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -32,13 +32,13 @@ ALLOWED_FILE_EXTENSIONS = ["tfl", "tflx"] -logger = logging.getLogger("tableau.endpoint.flows") +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from .. import DQWItem - from ..request_options import RequestOptions - from ...models.permissions_item import PermissionsRule - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DQWItem + from tableauserverclient.models.permissions_item import PermissionsRule + from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse FilePath = Union[str, os.PathLike] diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ba5b6649b..ad3828568 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.groups") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple, Union diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index dd210d990..d0b865e21 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -6,7 +6,7 @@ from ..request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer -logger = logging.getLogger("tableau.endpoint.jobs") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, Tuple, Union diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 06339fa79..39146d062 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import GraphQLError, InvalidGraphQLQuery -logger = logging.getLogger("tableau.endpoint.metadata") +from tableauserverclient.helpers.logging import logger def is_valid_paged_query(parsed_query): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 8443726cd..a0e984475 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -15,7 +15,7 @@ from ...server import Server -logger = logging.getLogger("tableau.endpoint.metrics") +from tableauserverclient.helpers.logging import logger class Metrics(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index e50e32945..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -8,7 +8,7 @@ from typing import Callable, TYPE_CHECKING, List, Optional, Union -logger = logging.getLogger(__name__) +from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: from ..server import Server diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 440940606..510f1ff3d 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -13,7 +13,7 @@ from ..server import Server from ..request_options import RequestOptions -logger = logging.getLogger("tableau.endpoint.projects") +from tableauserverclient.helpers.logging import logger class Projects(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 18c38798e..8177bd733 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,13 +1,13 @@ import copy -import logging import urllib.parse from .endpoint import Endpoint -from .exceptions import EndpointUnavailableError, ServerResponseError +from .exceptions import ServerResponseError +from ..exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem -logger = logging.getLogger("tableau.endpoint.resource_tagger") +from tableauserverclient.helpers.logging import logger class _ResourceTagger(Endpoint): diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 7cca1f5d5..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -9,7 +9,8 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem -logger = logging.getLogger("tableau.endpoint.schedules") +from tableauserverclient.helpers.logging import logger + AddResponse = namedtuple("AddResponse", ("result", "error", "warnings", "task_created")) OK = AddResponse(result=True, error=None, warnings=None, task_created=None) diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index b396a1f87..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,15 +1,13 @@ import logging from .endpoint import Endpoint, api -from .exceptions import ( - ServerResponseError, +from .exceptions import ServerResponseError +from ..exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) from tableauserverclient.models import ServerInfoItem -logger = logging.getLogger("tableau.endpoint.server_info") - class ServerInfo(Endpoint): def __init__(self, server): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index a4c765484..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SiteItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.sites") +from tableauserverclient.helpers.logging import logger from typing import TYPE_CHECKING, List, Optional, Tuple diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a81a2fbf0..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import SubscriptionItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.subscriptions") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index e51f885d7..dfb2e6d7c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import TableItem, ColumnItem, PaginationItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.tables") +from tableauserverclient.helpers.logging import logger class Tables(Endpoint): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index b903ac634..ad1702f58 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -5,7 +5,7 @@ from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory -logger = logging.getLogger("tableau.endpoint.tasks") +from tableauserverclient.helpers.logging import logger class Tasks(Endpoint): diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 5a9c74619..e8c5cc962 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem from ..pager import Pager -logger = logging.getLogger("tableau.endpoint.users") +from tableauserverclient.helpers.logging import logger class Users(QuerysetEndpoint): diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index c060298ba..9c4b90657 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -7,7 +7,7 @@ from .resource_tagger import _ResourceTagger from tableauserverclient.models import ViewItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.views") +from tableauserverclient.helpers.logging import logger from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 69a958988..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem -logger = logging.getLogger("tableau.endpoint.webhooks") +from tableauserverclient.helpers.logging import logger from typing import List, Optional, TYPE_CHECKING, Tuple diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e2784b55..a73b0f0d5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -44,7 +44,8 @@ ALLOWED_FILE_EXTENSIONS = ["twb", "twbx"] -logger = logging.getLogger("tableau.endpoint.workbooks") +from tableauserverclient.helpers.logging import logger + FilePath = Union[str, os.PathLike] FileObject = Union[io.BufferedReader, io.BytesIO] FileObjectR = Union[io.BufferedReader, io.BytesIO] diff --git a/tableauserverclient/server/exceptions.py b/tableauserverclient/server/exceptions.py index 09d3d0541..6c9bbcefc 100644 --- a/tableauserverclient/server/exceptions.py +++ b/tableauserverclient/server/exceptions.py @@ -1,2 +1,9 @@ -class NotSignedInError(Exception): +# These errors can be thrown without even talking to Tableau Server + + +class ServerInfoEndpointNotFoundError(Exception): + pass + + +class EndpointUnavailableError(Exception): pass diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4140794b4..4bd30bb2c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -197,6 +197,8 @@ def update_req(self, datasource_item): if datasource_item.owner_id: owner_element = ET.SubElement(datasource_element, "owner") owner_element.attrib["id"] = datasource_item.owner_id + if datasource_item.use_remote_query_agent is not None: + datasource_element.attrib["useRemoteQueryAgent"] = str(datasource_item.use_remote_query_agent).lower() datasource_element.attrib["isCertified"] = str(datasource_item.certified).lower() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 299b9db2f..1ee18e9df 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,7 +1,7 @@ from tableauserverclient.models.property_decorators import property_is_int import logging -logger = logging.getLogger("tableau.request_options") +from tableauserverclient.helpers.logging import logger class RequestOptionsBase(object): diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 887b9de6d..ee23789b1 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,4 +1,4 @@ -import logging +from tableauserverclient.helpers.logging import logger import requests import urllib3 @@ -34,11 +34,11 @@ Metrics, Endpoint, ) -from .endpoint.exceptions import ( +from .exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .exceptions import NotSignedInError +from .endpoint.exceptions import NotSignedInError from ..namespace import Namespace @@ -99,8 +99,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) - self.logger = logging.getLogger("TSC.server") - self._session = self._session_factory() self._http_options = dict() # must set this before making a server call if http_options: @@ -114,7 +112,8 @@ def __init__(self, server_address, use_server_version=False, http_options=None, def validate_connection_settings(self): try: - Endpoint(self).set_parameters(self._http_options, None, None, None, None) + params = Endpoint(self).set_parameters(self._http_options, None, None, None, None) + Endpoint.set_user_agent(params) if not self._server_address.startswith("https://") and not self._server_address.startswith("https://"): self._server_address = "https://" + self._server_address self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) @@ -156,8 +155,8 @@ def _get_legacy_version(self): try: info_xml = fromstring(response.content) except ParseError as parseError: - self.logger.info(parseError) - self.logger.info("Could not read server version info. The server may not be running or configured.") + logger.info(parseError) + logger.info("Could not read server version info. The server may not be running or configured.") return self.version prod_version = info_xml.find(".//product_version").text version = _PRODUCT_TO_REST_VERSION.get(prod_version, minimum_supported_server_version) @@ -168,15 +167,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - self.logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - self.logger.info("versions: {}, {}".format(version, old_version)) + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -184,7 +183,7 @@ def use_server_version(self): def use_highest_version(self): self.use_server_version() - self.logger.info("use use_server_version instead", DeprecationWarning) + logger.info("use use_server_version instead", DeprecationWarning) def check_at_least_version(self, target: str): server_version = Version(self.version or "2.4") diff --git a/test/test_auth.py b/test/test_auth.py index 40255f627..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.ServerResponseError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 4f3529762..730e382da 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -145,9 +145,9 @@ def test_update_copy_fields(self) -> None: def test_update_tags(self) -> None: add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -191,7 +191,7 @@ def test_update_connection(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", text=response_xml, ) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) @@ -610,7 +610,7 @@ def test_synchronous_publish_timeout_error(self) -> None: new_datasource = TSC.DatasourceItem(project_id="") publish_mode = self.server.PublishMode.CreateNew - + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds self.assertRaisesRegex( InternalServerError, "Please use asynchronous publishing to avoid timeouts.", diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0d8ae84f2..3d2d1c995 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -15,9 +15,32 @@ def setUp(self) -> None: # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + def test_fallback_request_logic(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + self.assertIsNotNone(response) + + def test_user_friendly_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + self.assertIsNotNone(response) + + def test_blocking_request_returns(self) -> None: + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) + def test_get_request_stream(self) -> None: url = "http://test/" endpoint = TSC.server.Endpoint(self.server) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 4d3b0c864..cf0861e24 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -43,7 +43,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -58,7 +58,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(self.baseurl + "/" + upload_id, text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9dacbe033..5d8bdf05e 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -22,7 +22,7 @@ class RequestOptionTests(unittest.TestCase): def setUp(self) -> None: - self.server = TSC.Server("http://test", False) + self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) # Fake signin self.server.version = "3.10" @@ -151,7 +151,7 @@ def test_multiple_filter_options(self) -> None: ) ) req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(100): + for _ in range(5): matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) @@ -245,7 +245,7 @@ def test_multiple_filter_options_shorthand(self) -> None: ) m.get(url, text=response_xml) - for _ in range(100): + for _ in range(5): matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") self.assertEqual(3, matching_workbooks.total_available) diff --git a/test/test_webhook.py b/test/test_webhook.py index ff8b7048e..5f26266b2 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -4,7 +4,8 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server import RequestFactory, WebhookItem +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import WebhookItem from ._utils import asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") From 5650adc5126a7fc0070fb8e68de848af709baa0d Mon Sep 17 00:00:00 2001 From: Lars Breddemann <139097050+LarsBreddemann@users.noreply.github.com> Date: Tue, 25 Jul 2023 15:48:17 +1000 Subject: [PATCH 307/567] 846 fix filter in operator spaces bug (#1259) * encode spaces in filter conditions as %20 * corrected string replacement for filter condition * removed trailing space from comment * added tests for filter with IN condition and spaces in names --- tableauserverclient/server/filter.py | 6 ++++- test/assets/request_option_filter_name_in.xml | 12 +++++++++ test/test_filter.py | 22 ++++++++++++++++ test/test_request_option.py | 25 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/assets/request_option_filter_name_in.xml create mode 100644 test/test_filter.py diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 8802321fd..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -11,7 +11,11 @@ def __init__(self, field, operator, value): def __str__(self): value_string = str(self._value) if isinstance(self._value, list): - value_string = value_string.replace(" ", "").replace("'", "") + # this should turn the string representation of the list + # from ['', '', ...] + # to [,] + # so effectively, remove any spaces between "," and "'" and then remove all "'" + value_string = value_string.replace(", '", ",'").replace("'", "") return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property diff --git a/test/assets/request_option_filter_name_in.xml b/test/assets/request_option_filter_name_in.xml new file mode 100644 index 000000000..9ec42b8ab --- /dev/null +++ b/test/assets/request_option_filter_name_in.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_filter.py b/test/test_filter.py new file mode 100644 index 000000000..e2121307f --- /dev/null +++ b/test/test_filter.py @@ -0,0 +1,22 @@ +import os +import unittest + +import tableauserverclient as TSC + + +class FilterTests(unittest.TestCase): + def setUp(self): + pass + + def test_filter_equal(self): + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") + + self.assertEqual(str(filter), "name:eq:Superstore") + + def test_filter_in(self): + # create a IN filter condition with project names that + # contain spaces and "special" characters + projects_to_find = ["default", "Salesforce Sales Projeśt"] + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) + + self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]") diff --git a/test/test_request_option.py b/test/test_request_option.py index 5d8bdf05e..32526d1e6 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -13,6 +13,7 @@ PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") +FILTER_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml") FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") @@ -114,6 +115,30 @@ def test_filter_tags_in(self) -> None: self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + # check if filtered projects with spaces & special characters + # get correctly returned + def test_filter_name_in(self) -> None: + with open(FILTER_NAME_IN, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get( + self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", + text=response_xml, + ) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["default", "Salesforce Sales Projeśt"], + ) + ) + matching_projects, pagination_item = self.server.projects.get(req_option) + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("default", matching_projects[0].name) + self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name) + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") From 66064c55b0aee851dba31d50b0ab7c23e0270acf Mon Sep 17 00:00:00 2001 From: jorwoods Date: Mon, 31 Jul 2023 15:46:13 -0500 Subject: [PATCH 308/567] fix: remove logging configuration from TSC (#1248) Co-authored-by: Jac Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> --- tableauserverclient/helpers/logging.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 414d85786..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,5 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) From f56b2c741d7e94630ec1b2d1d6ee4e9c31c5093f Mon Sep 17 00:00:00 2001 From: jorwoods Date: Tue, 1 Aug 2023 02:17:00 -0500 Subject: [PATCH 309/567] feat: add JWTAuth (#1219) * feat: add JWTAuth, add repr using qualname * chore: mark Credentials class and methods as abstract --- tableauserverclient/models/tableau_auth.py | 30 +++++++++++++++++-- .../server/endpoint/auth_endpoint.py | 28 +++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index db21e4aa2..30639d09b 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,13 +1,18 @@ -class Credentials: +import abc + + +class Credentials(abc.ABC): def __init__(self, site_id=None, user_id_to_impersonate=None): self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property + @abc.abstractmethod def credentials(self): credentials = "Credentials can be username/password, Personal Access Token, or JWT" +"This method returns values to set as an attribute on the credentials element of the request" + @abc.abstractmethod def __repr__(self): return "All Credentials types must have a debug display that does not print secrets" @@ -52,10 +57,10 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None): + def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") - super().__init__(site_id=site_id) + super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) self.token_name = token_name self.personal_access_token = personal_access_token @@ -70,3 +75,22 @@ def __repr__(self): return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) + + +class JWTAuth(Credentials): + def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + if jwt is None: + raise TabError("Must provide a JWT token when using JWT authentication") + super().__init__(site_id, user_id_to_impersonate) + self.jwt = jwt + + @property + def credentials(self): + return {"jwt": self.jwt} + + def __repr__(self): + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6f1ddc35e..2025de5fb 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,4 +1,6 @@ import logging +from typing import TYPE_CHECKING +import warnings from defusedxml.ElementTree import fromstring @@ -8,6 +10,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.site_item import SiteItem + from tableauserverclient.models.tableau_auth import Credentials + class Auth(Endpoint): class contextmgr(object): @@ -21,11 +27,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._callback() @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") - def sign_in(self, auth_req): + def sign_in(self, auth_req: "Credentials") -> contextmgr: + """ + Sign in to a Tableau Server or Tableau Online using a credentials object. + + The credentials object can either be a TableauAuth object, a + PersonalAccessTokenAuth object, or a JWTAuth object. This method now + accepts them all. The object should be populated with the site_id and + optionally a user_id to impersonate. + + Creates a context manager that will sign out of the server upon exit. + """ url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( @@ -51,12 +67,12 @@ def sign_in(self, auth_req): return Auth.contextmgr(self.sign_out) @api(version="3.6") - def sign_in_with_personal_access_token(self, auth_req): + def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: # We use the same request that username/password login uses. return self.sign_in(auth_req) @api(version="2.0") - def sign_out(self): + def sign_out(self) -> None: url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -66,7 +82,7 @@ def sign_out(self): logger.info("Signed out") @api(version="2.6") - def switch_site(self, site_item): + def switch_site(self, site_item: "SiteItem") -> contextmgr: url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -87,7 +103,7 @@ def switch_site(self, site_item): return Auth.contextmgr(self.sign_out) @api(version="3.10") - def revoke_all_server_admin_tokens(self): + def revoke_all_server_admin_tokens(self) -> None: url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") self.post_request(url, "") logger.info("Revoked all tokens for all server admins") From 77f2f63e62c860bc7e9f25a2f268e8b0a5e078ac Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 15:30:18 -0700 Subject: [PATCH 310/567] Jac/schedules (#1266) * Hotfix schedule_item.py for issue 1237 (#1239) * Remove duplicate assignments to fields (#1244) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Co-authored-by: Austin <110413815+austinpeters-gohealthuccom@users.noreply.github.com> Co-authored-by: Yasuhisa Yoshida --- tableauserverclient/models/datasource_item.py | 9 --------- tableauserverclient/models/schedule_item.py | 8 ++------ 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 7fcc31ebf..5a867135c 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -326,17 +326,8 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) certification_note = datasource_xml.get("certificationNote", None) certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - certification_note = datasource_xml.get("certificationNote", None) - certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - content_url = datasource_xml.get("contentUrl", None) - created_at = parse_datetime(datasource_xml.get("createdAt", None)) - datasource_type = datasource_xml.get("type", None) - description = datasource_xml.get("description", None) encrypt_extracts = datasource_xml.get("encryptExtracts", None) has_extracts = datasource_xml.get("hasExtracts", None) - id_ = datasource_xml.get("id", None) - name = datasource_xml.get("name", None) - updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 54e4badbe..edfd0fe70 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -14,8 +14,6 @@ ) from .property_decorators import ( property_is_enum, - property_not_nullable, - property_is_int, ) Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] @@ -27,6 +25,7 @@ class Type: Flow = "Flow" Subscription = "Subscription" DataAcceleration = "DataAcceleration" + ActiveDirectorySync = "ActiveDirectorySync" class ExecutionOrder: Parallel = "Parallel" @@ -74,11 +73,10 @@ def id(self) -> Optional[str]: return self._id @property - def name(self) -> str: + def name(self) -> Optional[str]: return self._name @name.setter - @property_not_nullable def name(self, value: str): self._name = value @@ -91,7 +89,6 @@ def priority(self) -> int: return self._priority @priority.setter - @property_is_int(range=(1, 100)) def priority(self, value: int): self._priority = value @@ -101,7 +98,6 @@ def schedule_type(self) -> str: @schedule_type.setter @property_is_enum(Type) - @property_not_nullable def schedule_type(self, value: str): self._schedule_type = value From 574118aed0a8a089fa482fa856e79f62d9c6fdd5 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:29:14 -0700 Subject: [PATCH 311/567] add powerpoint example in samples (#1262) --- samples/explore_workbook.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index c61b9b637..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -36,6 +36,9 @@ def main(): parser.add_argument( "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" ) + parser.add_argument( + "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck" + ) args = parser.parse_args() @@ -145,6 +148,13 @@ def main(): f.write(c.image) print("saved to " + filename) + if args.powerpoint: + # Populate workbook preview image + server.workbooks.populate_powerpoint(sample_workbook) + with open(args.powerpoint, "wb") as f: + f.write(sample_workbook.powerpoint) + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + if args.delete: print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) From 4caf0a5a948ed50a4d259c8ab65f5552b8544ace Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:30:31 -0700 Subject: [PATCH 312/567] pin black and mypy versions, update many dependencies (#1265) * pin black and mypy versions * drop python 3.7 support - update build + dependencies to latest versions - check mypy warnings, fix 2 typing complaints * update python versions used in actions to 3.8 -- 3.12 ( github doesn't have an image for 3.12 yet b/c it is still in beta?) --- .github/workflows/run-tests.yml | 4 ++-- pyproject.toml | 26 ++++++++++++++------------ test/models/test_repr.py | 2 +- test/test_datasource.py | 6 ++++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10df02c04..1f4614088 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} @@ -33,4 +33,4 @@ jobs: - name: Test build if: always() run: | - python -m build \ No newline at end of file + python -m build diff --git a/pyproject.toml b/pyproject.toml index ee793ec41..717ca7cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] +requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -12,39 +12,41 @@ license = {file = "LICENSE"} readme = "README.md" dependencies = [ - 'defusedxml>=0.7.1', - 'packaging>=22.0', # bumping to minimum version required by black - 'requests>=2.28', - 'urllib3~=1.26.8', + 'defusedxml>=0.7.1', # latest as at 7/31/23 + 'packaging>=23.1', # latest as at 7/31/23 + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.0.4', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10" + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] +check_untyped_defs = false disable_error_code = [ 'misc', - 'import' + # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] + 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test"] show_error_codes = true -ignore_missing_imports = true - +ignore_missing_imports = true # defusedxml library has no types [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/test/models/test_repr.py b/test/models/test_repr.py index f3da9fde2..d21e4bc4a 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,7 +1,7 @@ import pytest from unittest import TestCase -import _models +import _models # type: ignore # did not set types for this # ensure that all models have a __repr__ method implemented diff --git a/test/test_datasource.py b/test/test_datasource.py index 730e382da..e299e5291 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,12 +2,14 @@ import tempfile import unittest from io import BytesIO +from typing import Optional from zipfile import ZipFile import requests_mock from defusedxml.ElementTree import fromstring import tableauserverclient as TSC +from tableauserverclient import ConnectionItem from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads @@ -167,9 +169,9 @@ def test_populate_connections(self) -> None: single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections = single_datasource.connections + connections: Optional[list[ConnectionItem]] = single_datasource.connections - self.assertTrue(connections) + self.assertIsNotNone(connections) ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) From 282159291c0506a9d1ea79077227b59fb032e7e0 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:31:11 -0700 Subject: [PATCH 313/567] Add publish samples attribute (#1264) --- samples/create_project.py | 9 ++++++++- tableauserverclient/models/project_item.py | 2 ++ tableauserverclient/server/endpoint/projects_endpoint.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/samples/create_project.py b/samples/create_project.py index 611dbe366..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -57,7 +57,14 @@ def main(): server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name="Top Level Project") + # With the publish-samples attribute, the project will be created with sample items + top_level_project = TSC.ProjectItem( + name="Top Level Project", + description="A sample tsc project", + content_permissions=None, + parent_id=None, + samples=True, + ) top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 393a7990f..e7254ab5d 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -25,6 +25,7 @@ def __init__( description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, + samples: Optional[bool] = None, ) -> None: self._content_permissions = None self._id: Optional[str] = None @@ -32,6 +33,7 @@ def __init__( self.name: str = name self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id + self._samples: Optional[bool] = samples self._permissions = None self._default_workbook_permissions = None diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 510f1ff3d..99bb2e39b 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -63,6 +63,8 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl + if project_item._samples: + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] From 90cf3329d8298d41d0ea941f97f2b0e501fc8e0c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 1 Aug 2023 16:45:15 -0700 Subject: [PATCH 314/567] Update actions to newer versions to get supported Node versions (#1267) --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 4 ++-- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 6d74c5c38..d858c3389 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 3fcb852d1..7d6cd068a 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b8a70e9c5..fe8fffc42 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v4 with: python-version: 3.7 - name: Build dist files diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1f4614088..3df497806 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} From 15086b8cc200341255feed5392a82f4a8fa6895d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 1 Aug 2023 16:45:32 -0700 Subject: [PATCH 315/567] Added 'getting started' samples (#1263) * Added 'getting started' samples --- samples/getting_started/1_hello_server.py | 21 +++++ samples/getting_started/2_hello_site.py | 50 +++++++++++ samples/getting_started/3_hello_universe.py | 96 +++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 samples/getting_started/1_hello_server.py create mode 100644 samples/getting_started/2_hello_site.py create mode 100644 samples/getting_started/3_hello_universe.py diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py new file mode 100644 index 000000000..454b225de --- /dev/null +++ b/samples/getting_started/1_hello_server.py @@ -0,0 +1,21 @@ +#### +# Getting started Part One of Three +# This script demonstrates how to use the Tableau Server Client to connect to a server +# You don't need to have a site or any experience with Tableau to run it +# +#### + +import tableauserverclient as TSC + + +def main(): + # This is the domain for Tableau's Developer Program + server_url = "https://10ax.online.tableau.com" + server = TSC.Server(server_url) + print("Connected to {}".format(server.server_info.baseurl)) + print("Server information: {}".format(server.server_info)) + print("Sign up for a test site at https://www.tableau.com/developer") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py new file mode 100644 index 000000000..d62896059 --- /dev/null +++ b/samples/getting_started/2_hello_site.py @@ -0,0 +1,50 @@ +#### +# Getting started Part Two of Three +# This script demonstrates how to use the Tableau Server Client to +# view the content on an existing site on Tableau Server/Online +# It assumes that you have already got a site and can visit it in a browser +# +#### + +import getpass +import tableauserverclient as TSC + + +# 0 - launch your Tableau site in a web browser and look at the url to set the values below +def main(): + # 1 - replace with your server domain: stop at the slash + server_url = "https://10ax.online.tableau.com" + + # 2 - optional - change to false **for testing only** if you get a certificate error + use_ssl = True + + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in the url + # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 - replace with your username. + # REMEMBER: if you are using Tableau Online, your username is the entire email address + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, uncomment this section to use a Personal Access Token + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + project = projects[0] + print(project.name) + + print("Done") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py new file mode 100644 index 000000000..3ed39fd17 --- /dev/null +++ b/samples/getting_started/3_hello_universe.py @@ -0,0 +1,96 @@ +#### +# Getting Started Part Three of Three +# This script demonstrates all the different types of 'content' a server contains +# +# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info +#### + +import getpass +import tableauserverclient as TSC + + +def main(): + # 1 - replace with your server url + server_url = "https://10ax.online.tableau.com" + + # 2 - change to false **for testing only** if you get a certificate error + use_ssl = True + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in a url + # e.g https://my-server/#/this-is-your-site-url-name/ + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud) + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + for project in projects: + print(project.name) + + workbooks, pagination = server.datasources.get() + if workbooks: + print("{} workbooks".format(pagination.total_available)) + print(workbooks[0]) + + views, pagination = server.views.get() + if views: + print("{} views".format(pagination.total_available)) + print(views[0]) + + datasources, pagination = server.datasources.get() + if datasources: + print("{} datasources".format(pagination.total_available)) + print(datasources[0]) + + # I think all these other content types can go to a hello_universe script + # data alert, dqw, flow, ... do any of these require any add-ons? + jobs, pagination = server.jobs.get() + if jobs: + print("{} jobs".format(pagination.total_available)) + print(jobs[0]) + + metrics, pagination = server.metrics.get() + if metrics: + print("{} metrics".format(pagination.total_available)) + print(metrics[0]) + + schedules, pagination = server.schedules.get() + if schedules: + print("{} schedules".format(pagination.total_available)) + print(schedules[0]) + + tasks, pagination = server.tasks.get() + if tasks: + print("{} tasks".format(pagination.total_available)) + print(tasks[0]) + + webhooks, pagination = server.webhooks.get() + if webhooks: + print("{} webhooks".format(pagination.total_available)) + print(webhooks[0]) + + users, pagination = server.metrics.get() + if users: + print("{} users".format(pagination.total_available)) + print(users[0]) + + groups, pagination = server.groups.get() + if groups: + print("{} groups".format(pagination.total_available)) + print(groups[0]) + + if __name__ == "__main__": + main() From 01e03727a8c82d13d929c0abcb8c0136dc6a5a40 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 17 Aug 2023 11:56:54 -0700 Subject: [PATCH 316/567] Fix newline for clean Black run --- tableauserverclient/helpers/logging.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 860bed0fc..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,4 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") - From 5a5772ca804502dcfbba9e7b3674e3fc68722459 Mon Sep 17 00:00:00 2001 From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:00:25 -0700 Subject: [PATCH 317/567] add support for custom schedules in TOL (#1273) * add support for custom schedules in TOL --- samples/create_extract_task.py | 84 +++++++++++++++++++ .../models/subscription_item.py | 8 ++ .../server/endpoint/tasks_endpoint.py | 11 +++ tableauserverclient/server/request_factory.py | 28 +++++++ test/assets/tasks_create_extract_task.xml | 12 +++ test/test_task.py | 27 +++++- 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 samples/create_extract_task.py create mode 100644 test/assets/tasks_create_extract_task.xml diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py new file mode 100644 index 000000000..8408f67ee --- /dev/null +++ b/samples/create_extract_task.py @@ -0,0 +1,84 @@ +#### +# This script demonstrates how to create extract tasks in Tableau Cloud +# using the Tableau Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample extract refresh task.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Monthly Schedule + # This schedule will run on the 15th of every month at 11:30PM + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + + # Default to using first workbook found in server + all_workbook_items, pagination_item = server.workbooks.get() + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + + target_item = TSC.Target( + my_workbook.id, # the id of the workbook or datasource + "workbook", # alternatively can be "datasource" + ) + + extract_item = TSC.TaskItem( + None, + "FullRefresh", + None, + None, + None, + monthly_schedule, + None, + target_item, + ) + + try: + response = server.tasks.create(extract_item) + print(response) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e18adc6ae..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -4,6 +4,7 @@ from .property_decorators import property_is_boolean from .target import Target +from tableauserverclient.models import ScheduleItem if TYPE_CHECKING: from .target import Target @@ -23,6 +24,7 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.suspended = False self.target = target self.user_id = user_id + self.schedule = None def __repr__(self) -> str: if self.id is not None: @@ -92,9 +94,14 @@ def _parse_element(cls, element, ns): # Schedule element schedule_id = None + schedule = None if schedule_element is not None: schedule_id = schedule_element.get("id", None) + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + # Content element target = None send_if_view_empty = None @@ -127,6 +134,7 @@ def _parse_element(cls, element, ns): sub.page_size_option = page_size_option sub.send_if_view_empty = send_if_view_empty sub.suspended = suspended + sub.schedule = schedule return sub diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index ad1702f58..092597388 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -51,6 +51,17 @@ def get_by_id(self, task_id): server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="3.19") + def create(self, extract_item: TaskItem) -> TaskItem: + if not extract_item: + error = "No extract refresh provided" + raise ValueError(error) + logger.info("Creating an extract refresh ({})".format(extract_item)) + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + create_req = RequestFactory.Task.create_extract_req(extract_item) + server_response = self.post_request(url, create_req) + return server_response.content + @api(version="2.6") def run(self, task_item): if not task_item.id: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4bd30bb2c..7fb9bf9ed 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1028,6 +1028,34 @@ def run_req(self, xml_request, task_item): # Send an empty tsRequest pass + @_tsrequest_wrapped + def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: + extract_element = ET.SubElement(xml_request, "extractRefresh") + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = extract_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") + for interval in interval_item._interval_type_pairs(): + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/test/assets/tasks_create_extract_task.xml b/test/assets/tasks_create_extract_task.xml new file mode 100644 index 000000000..9e6310fba --- /dev/null +++ b/test/assets/tasks_create_extract_task.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index 5c432208d..4eb2c02e2 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,5 +1,6 @@ import os import unittest +from datetime import time import requests_mock @@ -15,12 +16,13 @@ GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") +GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.8" + self.server.version = "3.19" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -141,3 +143,26 @@ def test_run_now(self): self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) self.assertTrue("RefreshExtract" in job_response_content) + + def test_create_extract_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + target_item = TSC.Target("workbook_id", "workbook") + + task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post("{}".format(self.baseurl), text=response_xml) + create_response_content = self.server.tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("workbook_id" in create_response_content) + self.assertTrue("FullRefresh" in create_response_content) From 9afc0b30dd08dcebab7ef9a38d291aa46e5c0d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20W=C5=82odarczyk?= Date: Thu, 21 Sep 2023 22:13:41 +0200 Subject: [PATCH 318/567] Added Filtering Capability for Tableau Download View Crosstab Excel (#1281) add missing filters for crosstab --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 1ee18e9df..796f8add3 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -167,6 +167,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + self._append_view_filters(params) return params From 81af54ac7360d8bc929cf54f069d7c75320b4435 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 15:20:28 -0500 Subject: [PATCH 319/567] Enable asJob for group update (#1276) --- .../server/endpoint/groups_endpoint.py | 5 ++++- test/assets/group_update_async.xml | 10 ++++++++++ test/test_group.py | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/assets/group_update_async.xml diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ad3828568..ab5f672d1 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -82,14 +82,17 @@ def update( ) group_item.minimum_site_role = default_site_role + url = "{0}/{1}".format(self.baseurl, group_item.id) + if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) if as_job and (group_item.domain_name is None or group_item.domain_name == "local"): error = "Local groups cannot be updated asynchronously." raise ValueError(error) + elif as_job: + url = "?".join([url, "asJob=True"]) - url = "{0}/{1}".format(self.baseurl, group_item.id) update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) diff --git a/test/assets/group_update_async.xml b/test/assets/group_update_async.xml new file mode 100644 index 000000000..ea6b47eaa --- /dev/null +++ b/test/assets/group_update_async.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/test_group.py b/test/test_group.py index 306d42170..1edc50555 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,11 +1,14 @@ # encoding=utf-8 +from pathlib import Path import unittest import os import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" + +# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") @@ -16,6 +19,7 @@ CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") +UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml" class GroupTests(unittest.TestCase): @@ -245,3 +249,16 @@ def test_update_local_async(self) -> None: # mimic group returned from server where domain name is set to 'local' group.domain_name = "local" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) + + def test_update_ad_async(self) -> None: + group = TSC.GroupItem("myGroup", "example.com") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + group.minimum_site_role = TSC.UserItem.Roles.Viewer + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) + job = self.server.groups.update(group, as_job=True) + + self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupSync") From 3a49700d00db9c8c450e4248c08696c66d933f82 Mon Sep 17 00:00:00 2001 From: jorwoods Date: Thu, 21 Sep 2023 21:12:32 -0500 Subject: [PATCH 320/567] Fix shared attribute for custom views (#1280) --- tableauserverclient/models/custom_view_item.py | 5 +++++ test/test_custom_view.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index e0b47c738..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -134,6 +134,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi cv_item._content_url = custom_view_xml.get("contentUrl", None) cv_item._id = custom_view_xml.get("id", None) cv_item._name = custom_view_xml.get("name", None) + cv_item._shared = string_to_bool(custom_view_xml.get("shared", None)) if owner_elem is not None: parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) @@ -154,3 +155,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi all_view_items.append(cv_item) return all_view_items + + +def string_to_bool(s: Optional[str]) -> bool: + return (s or "").lower() == "true" diff --git a/test/test_custom_view.py b/test/test_custom_view.py index c1fe8c407..55dec5df1 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -41,14 +41,15 @@ def test_get(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) + self.assertFalse(all_views[0].shared) self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual("Overview", all_views[1].name) - self.assertEqual(False, all_views[1].shared) self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertTrue(all_views[1].shared) def test_get_by_id(self) -> None: with open(GET_XML_ID, "rb") as f: From f94f72d7dd82cdcff11e72037f699fe376684505 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 18 Apr 2023 19:59:35 -0700 Subject: [PATCH 321/567] run long requests on second thread (#1212) * run long requests on second thread * improve chunked upload requests * begin extracting constants for user editing * centrally configured logger --- .../server/endpoint/favorites_endpoint.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ac9e4b185..f6ab7d4b6 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,5 @@ from .endpoint import Endpoint, api +<<<<<<< HEAD from requests import Response from tableauserverclient.helpers.logging import logger @@ -16,6 +17,18 @@ ) from tableauserverclient.server import RequestFactory, RequestOptions from typing import Optional +======= +from tableauserverclient.server import RequestFactory +from tableauserverclient.models import FavoriteItem + +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem + from ..request_options import RequestOptions + +from tableauserverclient.helpers.logging import logger +>>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From c812e4bd24fac32bae4c3060fd805fae907b38a7 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 24 Apr 2023 12:10:37 -0700 Subject: [PATCH 322/567] fix imports --- .../server/endpoint/favorites_endpoint.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f6ab7d4b6..ee2ba9041 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,23 +1,4 @@ from .endpoint import Endpoint, api -<<<<<<< HEAD -from requests import Response - -from tableauserverclient.helpers.logging import logger -from tableauserverclient.models import ( - DatasourceItem, - FavoriteItem, - FlowItem, - MetricItem, - ProjectItem, - Resource, - TableauItem, - UserItem, - ViewItem, - WorkbookItem, -) -from tableauserverclient.server import RequestFactory, RequestOptions -from typing import Optional -======= from tableauserverclient.server import RequestFactory from tableauserverclient.models import FavoriteItem @@ -28,7 +9,6 @@ from ..request_options import RequestOptions from tableauserverclient.helpers.logging import logger ->>>>>>> 3cc28be (run long requests on second thread (#1212)) class Favorites(Endpoint): From 9f2e870ff9bbe8408494b80037fff4cd06ffe349 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 04:36:55 +0000 Subject: [PATCH 323/567] Sep 21, 2023, 9:36 PM --- tableauserverclient/server/endpoint/favorites_endpoint.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ee2ba9041..7e32a9d32 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,10 @@ from .endpoint import Endpoint, api from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem +from tableauserverclient.models import FavoriteItem, UserItem, Resource from typing import Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from ...models import DatasourceItem, FlowItem, ProjectItem, UserItem, ViewItem, WorkbookItem - from ..request_options import RequestOptions +from ..request_options import RequestOptions +from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem from tableauserverclient.helpers.logging import logger From c1e17ce9b109b1ece098f86c721e58a9eb1e6f98 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 22 Sep 2023 05:42:45 +0000 Subject: [PATCH 324/567] Sep 21, 2023, 10:42 PM --- .../server/endpoint/favorites_endpoint.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 7e32a9d32..f82b1b3d5 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,12 +1,20 @@ from .endpoint import Endpoint, api -from tableauserverclient.server import RequestFactory -from tableauserverclient.models import FavoriteItem, UserItem, Resource - -from typing import Optional, TYPE_CHECKING -from ..request_options import RequestOptions -from ...models import DatasourceItem, FlowItem, ProjectItem, ViewItem, WorkbookItem, TableauItem, MetricItem - +from requests import Response from tableauserverclient.helpers.logging import logger +from tableauserverclient.models import ( + DatasourceItem, + FavoriteItem, + FlowItem, + MetricItem, + ProjectItem, + Resource, + TableauItem, + UserItem, + ViewItem, + WorkbookItem, +) +from tableauserverclient.server import RequestFactory, RequestOptions +from typing import Optional class Favorites(Endpoint): From 341dcd27bfa5eadfd852486ed2e812e713239941 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 22 Sep 2023 11:08:14 -0700 Subject: [PATCH 325/567] 0.27 (#1272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled (#1216) * fix: make project optional in datasources #1210 * fix: allow setting timeout on workbook endpoint #1087 * fix: can't certify datasource on publish #1058 * fix filter in operator spaces bug (#1259) * fix: remove logging configuration from TSC (#1248) * Hotfix schedule_item.py for issue 1237 (#1239), Remove duplicate assignments to fields (#1244) * Fix shared attribute for custom views (#1280) New functionality * enable filtering for Excel downloads #1209, #1281 * query view by content url #456 * update datasource to use bridge (#1224) * Add JWTAuth, add repr using qualname * Add publish samples attribute (#1264) * add support for custom schedules in TOL (#1273) * Enable asJob for group update (#1276) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Co-authored-by: Lars Breddemann <139097050+LarsBreddemann@users.noreply.github.com> Co-authored-by: jorwoods Co-authored-by: Austin <110413815+austinpeters-gohealthuccom@users.noreply.github.com> Co-authored-by: Yasuhisa Yoshida Co-authored-by: Brian Cantoni Co-authored-by: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Co-authored-by: Łukasz Włodarczyk --- .github/workflows/code-coverage.yml | 4 +- .github/workflows/meta-checks.yml | 4 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 8 +- pyproject.toml | 26 ++--- samples/create_extract_task.py | 84 ++++++++++++++++ samples/create_project.py | 9 +- samples/explore_workbook.py | 10 ++ samples/getting_started/1_hello_server.py | 21 ++++ samples/getting_started/2_hello_site.py | 50 ++++++++++ samples/getting_started/3_hello_universe.py | 96 +++++++++++++++++++ tableauserverclient/helpers/logging.py | 2 - .../models/custom_view_item.py | 5 + tableauserverclient/models/datasource_item.py | 9 -- tableauserverclient/models/project_item.py | 2 + tableauserverclient/models/schedule_item.py | 8 +- .../models/subscription_item.py | 8 ++ tableauserverclient/models/tableau_auth.py | 30 +++++- .../server/endpoint/auth_endpoint.py | 28 ++++-- .../server/endpoint/favorites_endpoint.py | 1 - .../server/endpoint/groups_endpoint.py | 5 +- .../server/endpoint/projects_endpoint.py | 2 + .../server/endpoint/tasks_endpoint.py | 11 +++ tableauserverclient/server/filter.py | 6 +- tableauserverclient/server/request_factory.py | 28 ++++++ tableauserverclient/server/request_options.py | 1 + test/assets/group_update_async.xml | 10 ++ test/assets/request_option_filter_name_in.xml | 12 +++ test/assets/tasks_create_extract_task.xml | 12 +++ test/models/test_repr.py | 2 +- test/test_custom_view.py | 3 +- test/test_datasource.py | 6 +- test/test_filter.py | 22 +++++ test/test_group.py | 19 +++- test/test_request_option.py | 25 +++++ test/test_task.py | 27 +++++- 36 files changed, 541 insertions(+), 57 deletions(-) create mode 100644 samples/create_extract_task.py create mode 100644 samples/getting_started/1_hello_server.py create mode 100644 samples/getting_started/2_hello_site.py create mode 100644 samples/getting_started/3_hello_universe.py create mode 100644 test/assets/group_update_async.xml create mode 100644 test/assets/request_option_filter_name_in.xml create mode 100644 test/assets/tasks_create_extract_task.xml create mode 100644 test/test_filter.py diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 6d74c5c38..d858c3389 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 3fcb852d1..7d6cd068a 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b8a70e9c5..fe8fffc42 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v4 with: python-version: 3.7 - name: Build dist files diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10df02c04..3df497806 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,15 +8,15 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -33,4 +33,4 @@ jobs: - name: Test build if: always() run: | - python -m build \ No newline at end of file + python -m build diff --git a/pyproject.toml b/pyproject.toml index ee793ec41..717ca7cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] +requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -12,39 +12,41 @@ license = {file = "LICENSE"} readme = "README.md" dependencies = [ - 'defusedxml>=0.7.1', - 'packaging>=22.0', # bumping to minimum version required by black - 'requests>=2.28', - 'urllib3~=1.26.8', + 'defusedxml>=0.7.1', # latest as at 7/31/23 + 'packaging>=23.1', # latest as at 7/31/23 + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.0.4', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10" + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] +check_untyped_defs = false disable_error_code = [ 'misc', - 'import' + # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] + 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test"] show_error_codes = true -ignore_missing_imports = true - +ignore_missing_imports = true # defusedxml library has no types [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py new file mode 100644 index 000000000..8408f67ee --- /dev/null +++ b/samples/create_extract_task.py @@ -0,0 +1,84 @@ +#### +# This script demonstrates how to create extract tasks in Tableau Cloud +# using the Tableau Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample extract refresh task.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Monthly Schedule + # This schedule will run on the 15th of every month at 11:30PM + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + + # Default to using first workbook found in server + all_workbook_items, pagination_item = server.workbooks.get() + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + + target_item = TSC.Target( + my_workbook.id, # the id of the workbook or datasource + "workbook", # alternatively can be "datasource" + ) + + extract_item = TSC.TaskItem( + None, + "FullRefresh", + None, + None, + None, + monthly_schedule, + None, + target_item, + ) + + try: + response = server.tasks.create(extract_item) + print(response) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/samples/create_project.py b/samples/create_project.py index 611dbe366..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -57,7 +57,14 @@ def main(): server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name="Top Level Project") + # With the publish-samples attribute, the project will be created with sample items + top_level_project = TSC.ProjectItem( + name="Top Level Project", + description="A sample tsc project", + content_permissions=None, + parent_id=None, + samples=True, + ) top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index c61b9b637..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -36,6 +36,9 @@ def main(): parser.add_argument( "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" ) + parser.add_argument( + "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck" + ) args = parser.parse_args() @@ -145,6 +148,13 @@ def main(): f.write(c.image) print("saved to " + filename) + if args.powerpoint: + # Populate workbook preview image + server.workbooks.populate_powerpoint(sample_workbook) + with open(args.powerpoint, "wb") as f: + f.write(sample_workbook.powerpoint) + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + if args.delete: print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py new file mode 100644 index 000000000..454b225de --- /dev/null +++ b/samples/getting_started/1_hello_server.py @@ -0,0 +1,21 @@ +#### +# Getting started Part One of Three +# This script demonstrates how to use the Tableau Server Client to connect to a server +# You don't need to have a site or any experience with Tableau to run it +# +#### + +import tableauserverclient as TSC + + +def main(): + # This is the domain for Tableau's Developer Program + server_url = "https://10ax.online.tableau.com" + server = TSC.Server(server_url) + print("Connected to {}".format(server.server_info.baseurl)) + print("Server information: {}".format(server.server_info)) + print("Sign up for a test site at https://www.tableau.com/developer") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py new file mode 100644 index 000000000..d62896059 --- /dev/null +++ b/samples/getting_started/2_hello_site.py @@ -0,0 +1,50 @@ +#### +# Getting started Part Two of Three +# This script demonstrates how to use the Tableau Server Client to +# view the content on an existing site on Tableau Server/Online +# It assumes that you have already got a site and can visit it in a browser +# +#### + +import getpass +import tableauserverclient as TSC + + +# 0 - launch your Tableau site in a web browser and look at the url to set the values below +def main(): + # 1 - replace with your server domain: stop at the slash + server_url = "https://10ax.online.tableau.com" + + # 2 - optional - change to false **for testing only** if you get a certificate error + use_ssl = True + + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in the url + # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 - replace with your username. + # REMEMBER: if you are using Tableau Online, your username is the entire email address + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, uncomment this section to use a Personal Access Token + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + project = projects[0] + print(project.name) + + print("Done") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py new file mode 100644 index 000000000..3ed39fd17 --- /dev/null +++ b/samples/getting_started/3_hello_universe.py @@ -0,0 +1,96 @@ +#### +# Getting Started Part Three of Three +# This script demonstrates all the different types of 'content' a server contains +# +# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info +#### + +import getpass +import tableauserverclient as TSC + + +def main(): + # 1 - replace with your server url + server_url = "https://10ax.online.tableau.com" + + # 2 - change to false **for testing only** if you get a certificate error + use_ssl = True + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in a url + # e.g https://my-server/#/this-is-your-site-url-name/ + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud) + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + for project in projects: + print(project.name) + + workbooks, pagination = server.datasources.get() + if workbooks: + print("{} workbooks".format(pagination.total_available)) + print(workbooks[0]) + + views, pagination = server.views.get() + if views: + print("{} views".format(pagination.total_available)) + print(views[0]) + + datasources, pagination = server.datasources.get() + if datasources: + print("{} datasources".format(pagination.total_available)) + print(datasources[0]) + + # I think all these other content types can go to a hello_universe script + # data alert, dqw, flow, ... do any of these require any add-ons? + jobs, pagination = server.jobs.get() + if jobs: + print("{} jobs".format(pagination.total_available)) + print(jobs[0]) + + metrics, pagination = server.metrics.get() + if metrics: + print("{} metrics".format(pagination.total_available)) + print(metrics[0]) + + schedules, pagination = server.schedules.get() + if schedules: + print("{} schedules".format(pagination.total_available)) + print(schedules[0]) + + tasks, pagination = server.tasks.get() + if tasks: + print("{} tasks".format(pagination.total_available)) + print(tasks[0]) + + webhooks, pagination = server.webhooks.get() + if webhooks: + print("{} webhooks".format(pagination.total_available)) + print(webhooks[0]) + + users, pagination = server.metrics.get() + if users: + print("{} users".format(pagination.total_available)) + print(users[0]) + + groups, pagination = server.groups.get() + if groups: + print("{} groups".format(pagination.total_available)) + print(groups[0]) + + if __name__ == "__main__": + main() diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 414d85786..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,5 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index e0b47c738..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -134,6 +134,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi cv_item._content_url = custom_view_xml.get("contentUrl", None) cv_item._id = custom_view_xml.get("id", None) cv_item._name = custom_view_xml.get("name", None) + cv_item._shared = string_to_bool(custom_view_xml.get("shared", None)) if owner_elem is not None: parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) @@ -154,3 +155,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi all_view_items.append(cv_item) return all_view_items + + +def string_to_bool(s: Optional[str]) -> bool: + return (s or "").lower() == "true" diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 7fcc31ebf..5a867135c 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -326,17 +326,8 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) certification_note = datasource_xml.get("certificationNote", None) certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - certification_note = datasource_xml.get("certificationNote", None) - certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - content_url = datasource_xml.get("contentUrl", None) - created_at = parse_datetime(datasource_xml.get("createdAt", None)) - datasource_type = datasource_xml.get("type", None) - description = datasource_xml.get("description", None) encrypt_extracts = datasource_xml.get("encryptExtracts", None) has_extracts = datasource_xml.get("hasExtracts", None) - id_ = datasource_xml.get("id", None) - name = datasource_xml.get("name", None) - updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 393a7990f..e7254ab5d 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -25,6 +25,7 @@ def __init__( description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, + samples: Optional[bool] = None, ) -> None: self._content_permissions = None self._id: Optional[str] = None @@ -32,6 +33,7 @@ def __init__( self.name: str = name self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id + self._samples: Optional[bool] = samples self._permissions = None self._default_workbook_permissions = None diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 54e4badbe..edfd0fe70 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -14,8 +14,6 @@ ) from .property_decorators import ( property_is_enum, - property_not_nullable, - property_is_int, ) Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] @@ -27,6 +25,7 @@ class Type: Flow = "Flow" Subscription = "Subscription" DataAcceleration = "DataAcceleration" + ActiveDirectorySync = "ActiveDirectorySync" class ExecutionOrder: Parallel = "Parallel" @@ -74,11 +73,10 @@ def id(self) -> Optional[str]: return self._id @property - def name(self) -> str: + def name(self) -> Optional[str]: return self._name @name.setter - @property_not_nullable def name(self, value: str): self._name = value @@ -91,7 +89,6 @@ def priority(self) -> int: return self._priority @priority.setter - @property_is_int(range=(1, 100)) def priority(self, value: int): self._priority = value @@ -101,7 +98,6 @@ def schedule_type(self) -> str: @schedule_type.setter @property_is_enum(Type) - @property_not_nullable def schedule_type(self, value: str): self._schedule_type = value diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e18adc6ae..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -4,6 +4,7 @@ from .property_decorators import property_is_boolean from .target import Target +from tableauserverclient.models import ScheduleItem if TYPE_CHECKING: from .target import Target @@ -23,6 +24,7 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.suspended = False self.target = target self.user_id = user_id + self.schedule = None def __repr__(self) -> str: if self.id is not None: @@ -92,9 +94,14 @@ def _parse_element(cls, element, ns): # Schedule element schedule_id = None + schedule = None if schedule_element is not None: schedule_id = schedule_element.get("id", None) + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + # Content element target = None send_if_view_empty = None @@ -127,6 +134,7 @@ def _parse_element(cls, element, ns): sub.page_size_option = page_size_option sub.send_if_view_empty = send_if_view_empty sub.suspended = suspended + sub.schedule = schedule return sub diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index db21e4aa2..30639d09b 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,13 +1,18 @@ -class Credentials: +import abc + + +class Credentials(abc.ABC): def __init__(self, site_id=None, user_id_to_impersonate=None): self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property + @abc.abstractmethod def credentials(self): credentials = "Credentials can be username/password, Personal Access Token, or JWT" +"This method returns values to set as an attribute on the credentials element of the request" + @abc.abstractmethod def __repr__(self): return "All Credentials types must have a debug display that does not print secrets" @@ -52,10 +57,10 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None): + def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") - super().__init__(site_id=site_id) + super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) self.token_name = token_name self.personal_access_token = personal_access_token @@ -70,3 +75,22 @@ def __repr__(self): return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) + + +class JWTAuth(Credentials): + def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + if jwt is None: + raise TabError("Must provide a JWT token when using JWT authentication") + super().__init__(site_id, user_id_to_impersonate) + self.jwt = jwt + + @property + def credentials(self): + return {"jwt": self.jwt} + + def __repr__(self): + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6f1ddc35e..2025de5fb 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,4 +1,6 @@ import logging +from typing import TYPE_CHECKING +import warnings from defusedxml.ElementTree import fromstring @@ -8,6 +10,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.site_item import SiteItem + from tableauserverclient.models.tableau_auth import Credentials + class Auth(Endpoint): class contextmgr(object): @@ -21,11 +27,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._callback() @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") - def sign_in(self, auth_req): + def sign_in(self, auth_req: "Credentials") -> contextmgr: + """ + Sign in to a Tableau Server or Tableau Online using a credentials object. + + The credentials object can either be a TableauAuth object, a + PersonalAccessTokenAuth object, or a JWTAuth object. This method now + accepts them all. The object should be populated with the site_id and + optionally a user_id to impersonate. + + Creates a context manager that will sign out of the server upon exit. + """ url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( @@ -51,12 +67,12 @@ def sign_in(self, auth_req): return Auth.contextmgr(self.sign_out) @api(version="3.6") - def sign_in_with_personal_access_token(self, auth_req): + def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: # We use the same request that username/password login uses. return self.sign_in(auth_req) @api(version="2.0") - def sign_out(self): + def sign_out(self) -> None: url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -66,7 +82,7 @@ def sign_out(self): logger.info("Signed out") @api(version="2.6") - def switch_site(self, site_item): + def switch_site(self, site_item: "SiteItem") -> contextmgr: url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -87,7 +103,7 @@ def switch_site(self, site_item): return Auth.contextmgr(self.sign_out) @api(version="3.10") - def revoke_all_server_admin_tokens(self): + def revoke_all_server_admin_tokens(self) -> None: url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ac9e4b185..f82b1b3d5 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,6 +1,5 @@ from .endpoint import Endpoint, api from requests import Response - from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ad3828568..ab5f672d1 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -82,14 +82,17 @@ def update( ) group_item.minimum_site_role = default_site_role + url = "{0}/{1}".format(self.baseurl, group_item.id) + if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) if as_job and (group_item.domain_name is None or group_item.domain_name == "local"): error = "Local groups cannot be updated asynchronously." raise ValueError(error) + elif as_job: + url = "?".join([url, "asJob=True"]) - url = "{0}/{1}".format(self.baseurl, group_item.id) update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 510f1ff3d..99bb2e39b 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -63,6 +63,8 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl + if project_item._samples: + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index ad1702f58..092597388 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -51,6 +51,17 @@ def get_by_id(self, task_id): server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="3.19") + def create(self, extract_item: TaskItem) -> TaskItem: + if not extract_item: + error = "No extract refresh provided" + raise ValueError(error) + logger.info("Creating an extract refresh ({})".format(extract_item)) + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + create_req = RequestFactory.Task.create_extract_req(extract_item) + server_response = self.post_request(url, create_req) + return server_response.content + @api(version="2.6") def run(self, task_item): if not task_item.id: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 8802321fd..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -11,7 +11,11 @@ def __init__(self, field, operator, value): def __str__(self): value_string = str(self._value) if isinstance(self._value, list): - value_string = value_string.replace(" ", "").replace("'", "") + # this should turn the string representation of the list + # from ['', '', ...] + # to [,] + # so effectively, remove any spaces between "," and "'" and then remove all "'" + value_string = value_string.replace(", '", ",'").replace("'", "") return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4bd30bb2c..7fb9bf9ed 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1028,6 +1028,34 @@ def run_req(self, xml_request, task_item): # Send an empty tsRequest pass + @_tsrequest_wrapped + def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: + extract_element = ET.SubElement(xml_request, "extractRefresh") + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = extract_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") + for interval in interval_item._interval_type_pairs(): + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 1ee18e9df..796f8add3 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -167,6 +167,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + self._append_view_filters(params) return params diff --git a/test/assets/group_update_async.xml b/test/assets/group_update_async.xml new file mode 100644 index 000000000..ea6b47eaa --- /dev/null +++ b/test/assets/group_update_async.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/assets/request_option_filter_name_in.xml b/test/assets/request_option_filter_name_in.xml new file mode 100644 index 000000000..9ec42b8ab --- /dev/null +++ b/test/assets/request_option_filter_name_in.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/tasks_create_extract_task.xml b/test/assets/tasks_create_extract_task.xml new file mode 100644 index 000000000..9e6310fba --- /dev/null +++ b/test/assets/tasks_create_extract_task.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/models/test_repr.py b/test/models/test_repr.py index f3da9fde2..d21e4bc4a 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,7 +1,7 @@ import pytest from unittest import TestCase -import _models +import _models # type: ignore # did not set types for this # ensure that all models have a __repr__ method implemented diff --git a/test/test_custom_view.py b/test/test_custom_view.py index c1fe8c407..55dec5df1 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -41,14 +41,15 @@ def test_get(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) + self.assertFalse(all_views[0].shared) self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual("Overview", all_views[1].name) - self.assertEqual(False, all_views[1].shared) self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertTrue(all_views[1].shared) def test_get_by_id(self) -> None: with open(GET_XML_ID, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 730e382da..e299e5291 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,12 +2,14 @@ import tempfile import unittest from io import BytesIO +from typing import Optional from zipfile import ZipFile import requests_mock from defusedxml.ElementTree import fromstring import tableauserverclient as TSC +from tableauserverclient import ConnectionItem from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads @@ -167,9 +169,9 @@ def test_populate_connections(self) -> None: single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections = single_datasource.connections + connections: Optional[list[ConnectionItem]] = single_datasource.connections - self.assertTrue(connections) + self.assertIsNotNone(connections) ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) diff --git a/test/test_filter.py b/test/test_filter.py new file mode 100644 index 000000000..e2121307f --- /dev/null +++ b/test/test_filter.py @@ -0,0 +1,22 @@ +import os +import unittest + +import tableauserverclient as TSC + + +class FilterTests(unittest.TestCase): + def setUp(self): + pass + + def test_filter_equal(self): + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") + + self.assertEqual(str(filter), "name:eq:Superstore") + + def test_filter_in(self): + # create a IN filter condition with project names that + # contain spaces and "special" characters + projects_to_find = ["default", "Salesforce Sales Projeśt"] + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) + + self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]") diff --git a/test/test_group.py b/test/test_group.py index 306d42170..1edc50555 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,11 +1,14 @@ # encoding=utf-8 +from pathlib import Path import unittest import os import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" + +# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") @@ -16,6 +19,7 @@ CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") +UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml" class GroupTests(unittest.TestCase): @@ -245,3 +249,16 @@ def test_update_local_async(self) -> None: # mimic group returned from server where domain name is set to 'local' group.domain_name = "local" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) + + def test_update_ad_async(self) -> None: + group = TSC.GroupItem("myGroup", "example.com") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + group.minimum_site_role = TSC.UserItem.Roles.Viewer + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) + job = self.server.groups.update(group, as_job=True) + + self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupSync") diff --git a/test/test_request_option.py b/test/test_request_option.py index 5d8bdf05e..32526d1e6 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -13,6 +13,7 @@ PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") +FILTER_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml") FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") @@ -114,6 +115,30 @@ def test_filter_tags_in(self) -> None: self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + # check if filtered projects with spaces & special characters + # get correctly returned + def test_filter_name_in(self) -> None: + with open(FILTER_NAME_IN, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get( + self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", + text=response_xml, + ) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["default", "Salesforce Sales Projeśt"], + ) + ) + matching_projects, pagination_item = self.server.projects.get(req_option) + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("default", matching_projects[0].name) + self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name) + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_task.py b/test/test_task.py index 5c432208d..4eb2c02e2 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,5 +1,6 @@ import os import unittest +from datetime import time import requests_mock @@ -15,12 +16,13 @@ GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") +GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.8" + self.server.version = "3.19" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -141,3 +143,26 @@ def test_run_now(self): self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) self.assertTrue("RefreshExtract" in job_response_content) + + def test_create_extract_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + target_item = TSC.Target("workbook_id", "workbook") + + task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post("{}".format(self.baseurl), text=response_xml) + create_response_content = self.server.tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("workbook_id" in create_response_content) + self.assertTrue("FullRefresh" in create_response_content) From 2f6a34322fea14fbccd1c093e521be2236ebfb81 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 25 Sep 2023 16:17:47 -0700 Subject: [PATCH 326/567] Code coverage and pretty printing (#1283) * implement str and repr for a bunch more classes * also: versioning for JWT, user-impersonation (cherry picked from commit 4887a62f9cb874a69098e88f73a6e8edcd1ae78e) * fix code coverage action * use reflection to find all models for comprehensive testing. --------- Co-authored-by: Lee Graber --- .github/workflows/code-coverage.yml | 2 +- README.md | 2 +- pyproject.toml | 3 +- tableauserverclient/__init__.py | 43 ++++++++++++- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/column_item.py | 3 + .../models/connection_credentials.py | 7 +++ .../models/data_acceleration_report_item.py | 3 + tableauserverclient/models/group_item.py | 5 +- tableauserverclient/models/interval_item.py | 12 ++++ tableauserverclient/models/job_item.py | 11 +++- tableauserverclient/models/metric_item.py | 5 +- tableauserverclient/models/pagination_item.py | 3 + .../models/permissions_item.py | 11 ++-- tableauserverclient/models/schedule_item.py | 5 +- .../models/server_info_item.py | 2 +- tableauserverclient/models/site_item.py | 3 + tableauserverclient/models/table_item.py | 6 ++ tableauserverclient/models/tableau_auth.py | 21 +++++-- tableauserverclient/models/user_item.py | 5 +- tableauserverclient/models/view_item.py | 5 +- tableauserverclient/models/workbook_item.py | 5 +- .../server/endpoint/auth_endpoint.py | 7 ++- test/models/_models.py | 49 +++++++-------- test/models/test_repr.py | 63 +++++++++++-------- test/test_group_model.py | 10 --- 26 files changed, 205 insertions(+), 88 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d858c3389..2549773c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -30,7 +30,7 @@ jobs: # https://github.com/marketplace/actions/pytest-coverage-comment - name: Generate coverage report - run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main diff --git a/README.md b/README.md index 71bf9b023..ab6a66fae 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ For more information on installing and using TSC, see the documentation: ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/pyproject.toml b/pyproject.toml index 717ca7cde..8ec6df4d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", + "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 03e484372..c5c3c1922 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,47 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import * +from .models import ( + BackgroundJobItem, + ColumnItem, + ConnectionCredentials, + ConnectionItem, + CustomViewItem, + DQWItem, + DailyInterval, + DataAlertItem, + DatabaseItem, + DatasourceItem, + FavoriteItem, + FlowItem, + FlowRunItem, + FileuploadItem, + GroupItem, + HourlyInterval, + IntervalItem, + JobItem, + JWTAuth, + MetricItem, + MonthlyInterval, + PaginationItem, + Permission, + PermissionsRule, + PersonalAccessTokenAuth, + ProjectItem, + RevisionItem, + ScheduleItem, + SiteItem, + ServerInfoItem, + SubscriptionItem, + TableItem, + TableauAuth, + Target, + TaskItem, + UserItem, + ViewItem, + WebhookItem, + WeeklyInterval, + WorkbookItem, +) from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b4a52f753..03d692583 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -31,7 +31,7 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth from .tableau_types import Resource, TableauItem, plural_type from .tag_item import TagItem from .target import Target diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index dbf200d21..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -9,6 +9,9 @@ def __init__(self, name, description=None): self.description = description self.name = name + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + @property def id(self): return self._id diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index db65de0ad..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -15,6 +15,13 @@ def __init__(self, name, password, embed=True, oauth=False): self.embed = embed self.oauth = oauth + def __repr__(self): + if self.password: + print = "redacted" + else: + print = "None" + return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >" + @property def embed(self): return self._embed diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3c1d6ed40..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self): def __init__(self, comparison_records): self._comparison_records = comparison_records + def __repr__(self): + return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>" + @property def comparison_records(self): return self._comparison_records diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 96c3ae675..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -26,11 +26,9 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name - def __str__(self): + def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.__dict__) - __repr__ = __str__ - @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -48,7 +46,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..02b57591b 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -31,6 +31,9 @@ def __init__(self, start_time, end_time, interval_value): self.end_time = end_time self.interval = interval_value + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Hourly @@ -86,6 +89,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Daily @@ -114,6 +120,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Weekly @@ -148,6 +157,9 @@ def __init__(self, start_time, interval_value): self.start_time = start_time self.interval = str(interval_value) + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Monthly diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 5a2636246..61e7a8d18 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -117,12 +117,15 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at - def __repr__(self): + def __str__(self): return ( "".format(**self.__dict__) ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) @@ -202,6 +205,12 @@ def __init__( self._title = title self._subtitle = subtitle + def __str__(self): + return f"<{self.__class__.name} {self._id} {self._type}>" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def id(self) -> str: return self._id diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index e390d2c4d..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -115,9 +115,12 @@ def view_id(self, value: Optional[str]) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def __repr__(self): + def __str__(self): return "".format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response( cls, diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 2cb89dc5e..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -7,6 +7,9 @@ def __init__(self): self._page_size = None self._total_available = None + def __repr__(self): + return f"" + @property def page_number(self) -> int: return self._page_number diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1602b077f..d2b2227db 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,4 +1,3 @@ -import logging import xml.etree.ElementTree as ET from typing import Dict, List, Optional @@ -17,6 +16,9 @@ class Mode: Allow = "Allow" Deny = "Deny" + def __repr__(self): + return "" + class Capability: AddComment = "AddComment" ChangeHierarchy = "ChangeHierarchy" @@ -39,17 +41,18 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + def __repr__(self): + return "" + class PermissionsRule(object): def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities - def __str__(self): + def __repr__(self): return "".format(self.grantee, self.capabilities) - __repr__ = __str__ - @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..dc0eca948 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -48,9 +48,12 @@ def __init__(self, name: str, priority: int, schedule_type: str, execution_order self.priority: int = priority self.schedule_type: str = schedule_type - def __repr__(self): + def __str__(self): return ''.format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def created_at(self) -> Optional[datetime]: return self._created_at diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b180665dd..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -12,7 +12,7 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version - def __str__(self): + def __repr__(self): return ( "ServerInfoItem: [product version: " + self._product_version diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 813e812af..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -39,6 +39,9 @@ def __str__(self): + ">" ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 7fbaa32d2..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -19,6 +19,12 @@ def __init__(self, name, description=None): self._columns = None self._data_quality_warnings = None + def __str__(self): + return f"<{self.__class__.__name__} {self._id} {self._name} >" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def permissions(self): if self._permissions is None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 30639d09b..9aca206d7 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -43,7 +43,11 @@ def credentials(self): return {"name": self.username, "password": self.password} def __repr__(self): - return "".format(self.username, "") + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"" @property def site(self): @@ -56,6 +60,7 @@ def site(self, value): self.site_id = value +# A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): if personal_access_token is None or token_name is None: @@ -72,13 +77,19 @@ def credentials(self): } def __repr__(self): - return "(site={})".format( - self.token_name, self.personal_access_token[:2] + "...", self.site_id + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return ( + f"" ) +# A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) @@ -93,4 +104,4 @@ def __repr__(self): uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" else: uid = "" - return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a12f4b557..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -67,10 +67,13 @@ def __init__( return None - def __repr__(self) -> str: + def __str__(self) -> str: str_site_role = self.site_role or "None" return "".format(self.id, self.name, str_site_role) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def auth_setting(self) -> Optional[str]: return self._auth_setting diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index ef1fb0e52..90cff490b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -32,11 +32,14 @@ def __init__(self) -> None: self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 16e05498b..86a9a2f18 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -53,11 +53,14 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def connections(self) -> List[ConnectionItem]: if self._connections is None: diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 2025de5fb..0b6bac0c9 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -66,9 +66,14 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + # We use the same request that username/password login uses for all auth types. + # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: - # We use the same request that username/password login uses. + return self.sign_in(auth_req) + + @api(version="3.17") + def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: return self.sign_in(auth_req) @api(version="2.0") diff --git a/test/models/_models.py b/test/models/_models.py index a1630da9c..59011c6c3 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -1,61 +1,58 @@ from tableauserverclient import * -# mmm. why aren't these available in the tsc namespace? +# TODO why aren't these available in the tsc namespace? Probably a bug. from tableauserverclient.models import ( DataAccelerationReportItem, - FavoriteItem, Credentials, ServerInfoItem, Resource, TableauItem, - plural_type, ) def get_defined_models(): - # not clever: copied from tsc/models/__init__.py + # nothing clever here: list was manually copied from tsc/models/__init__.py return [ - ColumnItem, - ConnectionCredentials, + BackgroundJobItem, ConnectionItem, DataAccelerationReportItem, DataAlertItem, - DatabaseItem, DatasourceItem, - DQWItem, - UnpopulatedPropertyError, - FavoriteItem, FlowItem, - FlowRunItem, GroupItem, - IntervalItem, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, JobItem, - BackgroundJobItem, MetricItem, - PaginationItem, PermissionsRule, - Permission, ProjectItem, RevisionItem, ScheduleItem, - ServerInfoItem, - SiteItem, SubscriptionItem, - TableItem, Credentials, + JWTAuth, TableauAuth, PersonalAccessTokenAuth, - Resource, - TableauItem, - plural_type, - Target, + ServerInfoItem, + SiteItem, TaskItem, UserItem, ViewItem, WebhookItem, WorkbookItem, + PaginationItem, + Permission.Mode, + Permission.Capability, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + TableItem, + Target, + ] + + +def get_unimplemented_models(): + return [ + FavoriteItem, # no repr because there is no state + Resource, # list of type names + TableauItem, # should be an interface ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index d21e4bc4a..92d11978f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,40 +1,51 @@ -import pytest +import inspect from unittest import TestCase import _models # type: ignore # did not set types for this +import tableauserverclient as TSC +from typing import Any -# ensure that all models have a __repr__ method implemented -class TestAllModels(TestCase): - """ - ColumnItem wrapper_descriptor - ConnectionCredentials wrapper_descriptor - DataAccelerationReportItem wrapper_descriptor - DatabaseItem wrapper_descriptor - DQWItem wrapper_descriptor - UnpopulatedPropertyError wrapper_descriptor - FavoriteItem wrapper_descriptor - FlowRunItem wrapper_descriptor - IntervalItem wrapper_descriptor - DailyInterval wrapper_descriptor - WeeklyInterval wrapper_descriptor - MonthlyInterval wrapper_descriptor - HourlyInterval wrapper_descriptor - BackgroundJobItem wrapper_descriptor - PaginationItem wrapper_descriptor - Permission wrapper_descriptor - ServerInfoItem wrapper_descriptor - SiteItem wrapper_descriptor - TableItem wrapper_descriptor - Resource wrapper_descriptor - """ +# ensure that all models that don't need parameters can be instantiated +# todo.... +def instantiate_class(name: str, obj: Any): + # Get the constructor (init) of the class + constructor = getattr(obj, "__init__", None) + if constructor: + # Get the parameters of the constructor (excluding 'self') + parameters = inspect.signature(constructor).parameters.values() + required_parameters = [ + param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self" + ] + if required_parameters: + print(f"Class '{name}' requires the following parameters for instantiation:") + for param in required_parameters: + print(f"- {param.name}") + else: + print(f"Class '{name}' does not require any parameters for instantiation.") + # Instantiate the class + instance = obj() + print(f"Instantiated: {name} -> {instance}") + else: + print(f"Class '{name}' does not have a constructor (__init__ method).") + +class TestAllModels(TestCase): # not all models have __repr__ yet: see above list - @pytest.mark.xfail() def test_repr_is_implemented(self): m = _models.get_defined_models() for model in m: with self.subTest(model.__name__, model=model): print(model.__name__, type(model.__repr__).__name__) self.assertEqual(type(model.__repr__).__name__, "function") + + # 2 - Iterate through the objects in the module + def test_by_reflection(self): + for class_name, obj in inspect.getmembers(TSC, is_concrete): + with self.subTest(class_name, obj=obj): + instantiate_class(class_name, obj) + + +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) diff --git a/test/test_group_model.py b/test/test_group_model.py index 6b79dc18a..659a3611f 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -4,16 +4,6 @@ class GroupModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.GroupItem, None) - self.assertRaises(ValueError, TSC.GroupItem, "") - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.name = None - - with self.assertRaises(ValueError): - group.name = "" - def test_invalid_minimum_site_role(self): group = TSC.GroupItem("grp") with self.assertRaises(ValueError): From 6d8bbe82007969084d51218fee87574e85d08c6f Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 28 Sep 2023 18:13:52 -0700 Subject: [PATCH 327/567] update publish action (#1286) * 0.27 (#1272) --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fe8fffc42..330bfe7d3 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.9 - name: Build dist files run: | python -m pip install --upgrade pip From d9cc13460f450bc7e505fab32c4e0b640b120986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 19:04:31 -0700 Subject: [PATCH 328/567] Bump urllib3 from 2.0.4 to 2.0.6 (#1287) * Bump urllib3 from 2.0.4 to 2.0.6 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.4 to 2.0.6. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.4...2.0.6) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ec6df4d5..12f4fb8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.0.4', # latest as at 7/31/23 + 'urllib3==2.0.6', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ From 72eb3c8500193e4f20defa20c8a6f8bbf34b2f43 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 4 Oct 2023 00:33:03 -0700 Subject: [PATCH 329/567] 0.28 - JWT Auth (#1288) * Code coverage and pretty printing (#1283) * implement str and repr for a bunch more classes * also: JWT, user-impersonation (cherry picked from commit 4887a62f9cb874a69098e88f73a6e8edcd1ae78e) * fix code coverage action * use reflection to find all models for comprehensive testing. --------- Co-authored-by: Lee Graber * update publish action (#1286) * 0.27 (#1272) * Bump urllib3 from 2.0.4 to 2.0.6 (#1287) * Bump urllib3 from 2.0.4 to 2.0.6 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.4 to 2.0.6. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.4...2.0.6) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: Lee Graber Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- README.md | 2 +- pyproject.toml | 5 +- tableauserverclient/__init__.py | 43 ++++++++++++- tableauserverclient/models/__init__.py | 2 +- tableauserverclient/models/column_item.py | 3 + .../models/connection_credentials.py | 7 +++ .../models/data_acceleration_report_item.py | 3 + tableauserverclient/models/group_item.py | 5 +- tableauserverclient/models/interval_item.py | 12 ++++ tableauserverclient/models/job_item.py | 11 +++- tableauserverclient/models/metric_item.py | 5 +- tableauserverclient/models/pagination_item.py | 3 + .../models/permissions_item.py | 11 ++-- tableauserverclient/models/schedule_item.py | 5 +- .../models/server_info_item.py | 2 +- tableauserverclient/models/site_item.py | 3 + tableauserverclient/models/table_item.py | 6 ++ tableauserverclient/models/tableau_auth.py | 21 +++++-- tableauserverclient/models/user_item.py | 5 +- tableauserverclient/models/view_item.py | 5 +- tableauserverclient/models/workbook_item.py | 5 +- .../server/endpoint/auth_endpoint.py | 7 ++- test/models/_models.py | 49 +++++++-------- test/models/test_repr.py | 63 +++++++++++-------- test/test_group_model.py | 10 --- 27 files changed, 207 insertions(+), 90 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d858c3389..2549773c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -30,7 +30,7 @@ jobs: # https://github.com/marketplace/actions/pytest-coverage-comment - name: Generate coverage report - run: pytest --junitxml=pytest.xml --cov=tableauserverclient tests/ | tee pytest-coverage.txt + run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fe8fffc42..330bfe7d3 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.9 - name: Build dist files run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index 71bf9b023..ab6a66fae 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,4 @@ For more information on installing and using TSC, see the documentation: ## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) \ No newline at end of file +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/pyproject.toml b/pyproject.toml index 717ca7cde..12f4fb8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.0.4', # latest as at 7/31/23 + 'urllib3==2.0.6', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ @@ -31,7 +31,8 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", + "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 03e484372..c5c3c1922 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,47 @@ from ._version import get_versions from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import * +from .models import ( + BackgroundJobItem, + ColumnItem, + ConnectionCredentials, + ConnectionItem, + CustomViewItem, + DQWItem, + DailyInterval, + DataAlertItem, + DatabaseItem, + DatasourceItem, + FavoriteItem, + FlowItem, + FlowRunItem, + FileuploadItem, + GroupItem, + HourlyInterval, + IntervalItem, + JobItem, + JWTAuth, + MetricItem, + MonthlyInterval, + PaginationItem, + Permission, + PermissionsRule, + PersonalAccessTokenAuth, + ProjectItem, + RevisionItem, + ScheduleItem, + SiteItem, + ServerInfoItem, + SubscriptionItem, + TableItem, + TableauAuth, + Target, + TaskItem, + UserItem, + ViewItem, + WebhookItem, + WeeklyInterval, + WorkbookItem, +) from .server import ( CSVRequestOptions, ExcelRequestOptions, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index b4a52f753..03d692583 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -31,7 +31,7 @@ from .site_item import SiteItem from .subscription_item import SubscriptionItem from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth +from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth from .tableau_types import Resource, TableauItem, plural_type from .tag_item import TagItem from .target import Target diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index dbf200d21..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -9,6 +9,9 @@ def __init__(self, name, description=None): self.description = description self.name = name + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + @property def id(self): return self._id diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index db65de0ad..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -15,6 +15,13 @@ def __init__(self, name, password, embed=True, oauth=False): self.embed = embed self.oauth = oauth + def __repr__(self): + if self.password: + print = "redacted" + else: + print = "None" + return f"<{self.__class__.__name__} name={self.name} password={print} embed={self.embed} oauth={self.oauth} >" + @property def embed(self): return self._embed diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3c1d6ed40..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -46,6 +46,9 @@ def avg_non_accelerated_plt(self): def __init__(self, comparison_records): self._comparison_records = comparison_records + def __repr__(self): + return f"<(deprecated)DataAccelerationReportItem site={self.site} sheet={sheet_uri}>" + @property def comparison_records(self): return self._comparison_records diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 96c3ae675..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -26,11 +26,9 @@ def __init__(self, name=None, domain_name=None) -> None: self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name - def __str__(self): + def __repr__(self): return "{}({!r})".format(self.__class__.__name__, self.__dict__) - __repr__ = __str__ - @property def domain_name(self) -> Optional[str]: return self._domain_name @@ -48,7 +46,6 @@ def name(self) -> Optional[str]: return self._name @name.setter - @property_not_empty def name(self, value: str) -> None: self._name = value diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..02b57591b 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -31,6 +31,9 @@ def __init__(self, start_time, end_time, interval_value): self.end_time = end_time self.interval = interval_value + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Hourly @@ -86,6 +89,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Daily @@ -114,6 +120,9 @@ def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Weekly @@ -148,6 +157,9 @@ def __init__(self, start_time, interval_value): self.start_time = start_time self.interval = str(interval_value) + def __repr__(self): + return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" + @property def _frequency(self): return IntervalItem.Frequency.Monthly diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 5a2636246..61e7a8d18 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -117,12 +117,15 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at - def __repr__(self): + def __str__(self): return ( "".format(**self.__dict__) ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) @@ -202,6 +205,12 @@ def __init__( self._title = title self._subtitle = subtitle + def __str__(self): + return f"<{self.__class__.name} {self._id} {self._type}>" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def id(self) -> str: return self._id diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index e390d2c4d..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -115,9 +115,12 @@ def view_id(self, value: Optional[str]) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def __repr__(self): + def __str__(self): return "".format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @classmethod def from_response( cls, diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 2cb89dc5e..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -7,6 +7,9 @@ def __init__(self): self._page_size = None self._total_available = None + def __repr__(self): + return f"" + @property def page_number(self) -> int: return self._page_number diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 1602b077f..d2b2227db 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,4 +1,3 @@ -import logging import xml.etree.ElementTree as ET from typing import Dict, List, Optional @@ -17,6 +16,9 @@ class Mode: Allow = "Allow" Deny = "Deny" + def __repr__(self): + return "" + class Capability: AddComment = "AddComment" ChangeHierarchy = "ChangeHierarchy" @@ -39,17 +41,18 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + def __repr__(self): + return "" + class PermissionsRule(object): def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities - def __str__(self): + def __repr__(self): return "".format(self.grantee, self.capabilities) - __repr__ = __str__ - @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..dc0eca948 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -48,9 +48,12 @@ def __init__(self, name: str, priority: int, schedule_type: str, execution_order self.priority: int = priority self.schedule_type: str = schedule_type - def __repr__(self): + def __str__(self): return ''.format(**vars(self)) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def created_at(self) -> Optional[datetime]: return self._created_at diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b180665dd..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -12,7 +12,7 @@ def __init__(self, product_version, build_number, rest_api_version): self._build_number = build_number self._rest_api_version = rest_api_version - def __str__(self): + def __repr__(self): return ( "ServerInfoItem: [product version: " + self._product_version diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 813e812af..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -39,6 +39,9 @@ def __str__(self): + ">" ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + class AdminMode: ContentAndUsers: str = "ContentAndUsers" ContentOnly: str = "ContentOnly" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 7fbaa32d2..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -19,6 +19,12 @@ def __init__(self, name, description=None): self._columns = None self._data_quality_warnings = None + def __str__(self): + return f"<{self.__class__.__name__} {self._id} {self._name} >" + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def permissions(self): if self._permissions is None: diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 30639d09b..9aca206d7 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -43,7 +43,11 @@ def credentials(self): return {"name": self.username, "password": self.password} def __repr__(self): - return "".format(self.username, "") + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"" @property def site(self): @@ -56,6 +60,7 @@ def site(self, value): self.site_id = value +# A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): if personal_access_token is None or token_name is None: @@ -72,13 +77,19 @@ def credentials(self): } def __repr__(self): - return "(site={})".format( - self.token_name, self.personal_access_token[:2] + "...", self.site_id + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return ( + f"" ) +# A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) @@ -93,4 +104,4 @@ def __repr__(self): uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" else: uid = "" - return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index a12f4b557..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -67,10 +67,13 @@ def __init__( return None - def __repr__(self) -> str: + def __str__(self) -> str: str_site_role = self.site_role or "None" return "".format(self.id, self.name, str_site_role) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def auth_setting(self) -> Optional[str]: return self._auth_setting diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index ef1fb0e52..90cff490b 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -32,11 +32,14 @@ def __init__(self) -> None: self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + def _set_preview_image(self, preview_image): self._preview_image = preview_image diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 16e05498b..86a9a2f18 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -53,11 +53,14 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None - def __repr__(self): + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id ) + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def connections(self) -> List[ConnectionItem]: if self._connections is None: diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 2025de5fb..0b6bac0c9 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -66,9 +66,14 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) + # We use the same request that username/password login uses for all auth types. + # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: - # We use the same request that username/password login uses. + return self.sign_in(auth_req) + + @api(version="3.17") + def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: return self.sign_in(auth_req) @api(version="2.0") diff --git a/test/models/_models.py b/test/models/_models.py index a1630da9c..59011c6c3 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -1,61 +1,58 @@ from tableauserverclient import * -# mmm. why aren't these available in the tsc namespace? +# TODO why aren't these available in the tsc namespace? Probably a bug. from tableauserverclient.models import ( DataAccelerationReportItem, - FavoriteItem, Credentials, ServerInfoItem, Resource, TableauItem, - plural_type, ) def get_defined_models(): - # not clever: copied from tsc/models/__init__.py + # nothing clever here: list was manually copied from tsc/models/__init__.py return [ - ColumnItem, - ConnectionCredentials, + BackgroundJobItem, ConnectionItem, DataAccelerationReportItem, DataAlertItem, - DatabaseItem, DatasourceItem, - DQWItem, - UnpopulatedPropertyError, - FavoriteItem, FlowItem, - FlowRunItem, GroupItem, - IntervalItem, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, JobItem, - BackgroundJobItem, MetricItem, - PaginationItem, PermissionsRule, - Permission, ProjectItem, RevisionItem, ScheduleItem, - ServerInfoItem, - SiteItem, SubscriptionItem, - TableItem, Credentials, + JWTAuth, TableauAuth, PersonalAccessTokenAuth, - Resource, - TableauItem, - plural_type, - Target, + ServerInfoItem, + SiteItem, TaskItem, UserItem, ViewItem, WebhookItem, WorkbookItem, + PaginationItem, + Permission.Mode, + Permission.Capability, + DailyInterval, + WeeklyInterval, + MonthlyInterval, + HourlyInterval, + TableItem, + Target, + ] + + +def get_unimplemented_models(): + return [ + FavoriteItem, # no repr because there is no state + Resource, # list of type names + TableauItem, # should be an interface ] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index d21e4bc4a..92d11978f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,40 +1,51 @@ -import pytest +import inspect from unittest import TestCase import _models # type: ignore # did not set types for this +import tableauserverclient as TSC +from typing import Any -# ensure that all models have a __repr__ method implemented -class TestAllModels(TestCase): - """ - ColumnItem wrapper_descriptor - ConnectionCredentials wrapper_descriptor - DataAccelerationReportItem wrapper_descriptor - DatabaseItem wrapper_descriptor - DQWItem wrapper_descriptor - UnpopulatedPropertyError wrapper_descriptor - FavoriteItem wrapper_descriptor - FlowRunItem wrapper_descriptor - IntervalItem wrapper_descriptor - DailyInterval wrapper_descriptor - WeeklyInterval wrapper_descriptor - MonthlyInterval wrapper_descriptor - HourlyInterval wrapper_descriptor - BackgroundJobItem wrapper_descriptor - PaginationItem wrapper_descriptor - Permission wrapper_descriptor - ServerInfoItem wrapper_descriptor - SiteItem wrapper_descriptor - TableItem wrapper_descriptor - Resource wrapper_descriptor - """ +# ensure that all models that don't need parameters can be instantiated +# todo.... +def instantiate_class(name: str, obj: Any): + # Get the constructor (init) of the class + constructor = getattr(obj, "__init__", None) + if constructor: + # Get the parameters of the constructor (excluding 'self') + parameters = inspect.signature(constructor).parameters.values() + required_parameters = [ + param for param in parameters if param.default == inspect.Parameter.empty and param.name != "self" + ] + if required_parameters: + print(f"Class '{name}' requires the following parameters for instantiation:") + for param in required_parameters: + print(f"- {param.name}") + else: + print(f"Class '{name}' does not require any parameters for instantiation.") + # Instantiate the class + instance = obj() + print(f"Instantiated: {name} -> {instance}") + else: + print(f"Class '{name}' does not have a constructor (__init__ method).") + +class TestAllModels(TestCase): # not all models have __repr__ yet: see above list - @pytest.mark.xfail() def test_repr_is_implemented(self): m = _models.get_defined_models() for model in m: with self.subTest(model.__name__, model=model): print(model.__name__, type(model.__repr__).__name__) self.assertEqual(type(model.__repr__).__name__, "function") + + # 2 - Iterate through the objects in the module + def test_by_reflection(self): + for class_name, obj in inspect.getmembers(TSC, is_concrete): + with self.subTest(class_name, obj=obj): + instantiate_class(class_name, obj) + + +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) diff --git a/test/test_group_model.py b/test/test_group_model.py index 6b79dc18a..659a3611f 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -4,16 +4,6 @@ class GroupModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.GroupItem, None) - self.assertRaises(ValueError, TSC.GroupItem, "") - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.name = None - - with self.assertRaises(ValueError): - group.name = "" - def test_invalid_minimum_site_role(self): group = TSC.GroupItem("grp") with self.assertRaises(ValueError): From eaa45d8b52b2047d19697ab1f185693975f1fb2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:08:42 +0000 Subject: [PATCH 330/567] Bump urllib3 from 2.0.6 to 2.0.7 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.6...2.0.7) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 12f4fb8c1..9c35a42e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.0.6', # latest as at 7/31/23 + 'urllib3==2.0.7', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ From 3fefcfabdfc66819a6c8e6c1c66ea09a66c00dbe Mon Sep 17 00:00:00 2001 From: gregg Date: Thu, 19 Oct 2023 19:00:45 +0000 Subject: [PATCH 331/567] Fix for #1301 of duplicate default permission requests 1. logging to the root logger isn't correct 2. the log line calls fetch_call() which makes a server request 3. retuns the results of fetch_call() which is never used anywhere Removing these lines from _set_default_permissions makes it more functionally equivalent to the above _set_permissions --- tableauserverclient/models/project_item.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index e7254ab5d..4918f1a14 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -163,9 +163,6 @@ def _set_default_permissions(self, permissions, content_type): attr, permissions, ) - fetch_call = getattr(self, attr) - logging.getLogger().info({"type": attr, "value": fetch_call()}) - return fetch_call() @classmethod def from_response(cls, resp, ns) -> List["ProjectItem"]: From ca4f9bebff63d2e3d91a5abf638497b641628dab Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 9 Nov 2023 17:06:35 -0800 Subject: [PATCH 332/567] Allow check to continue even if MishaKav/pytest-coverage-comment fails Right now this action is failing with what appears to be this issue: https://github.com/MishaKav/pytest-coverage-comment/issues/68 It seems to be failing on PRs from outside contributors only, so making this change will let thoses PRs through while we sort this out. --- .github/workflows/code-coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 2549773c0..e153c1fc7 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -33,6 +33,7 @@ jobs: run: pytest --junitxml=pytest.xml --cov=tableauserverclient test/ | tee pytest-coverage.txt - name: Comment on pull request with coverage + continue-on-error: true uses: MishaKav/pytest-coverage-comment@main with: pytest-coverage-path: ./pytest-coverage.txt From 25a59d0f8f54fb872c068b2f14cf366dd3a18e76 Mon Sep 17 00:00:00 2001 From: Fumiya Suto Date: Mon, 13 Nov 2023 20:26:42 +0900 Subject: [PATCH 333/567] Fixed type annotation for workbook.refresh `workbook.refresh` is implemented to accept both `WorkbookItem` and `str` as arguments, but the type annotation describes it as receiving `str`, which can cause false warnings in static analysis. Since the documentation states that it receives `workbook_item`, the name of the argument is also changed from `workbook_id` to `workbook_item`. Issue: https://github.com/tableau/server-client-python/issues/1318 --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index a73b0f0d5..3c8efbe3b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -88,8 +88,8 @@ 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_id: str) -> JobItem: - id_ = getattr(workbook_id, "id", workbook_id) + def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + id_ = getattr(workbook_item, "id", workbook_item) url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) From 5b73beb145b9378cd7ef3c7a2c46a8214605a399 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 18 Nov 2023 11:01:45 -0800 Subject: [PATCH 334/567] Remove comment with fake password that was causing confusion --- tableauserverclient/helpers/strings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index e51a6611a..75534103b 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -9,8 +9,6 @@ T = TypeVar("T", str, bytes) -# usage: _redact_any_type("") -# -> b" def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: try: root = fromstring(xml) From 082cec0b6a063117eee61ef43b08ecdde7d11e43 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 25 Oct 2023 20:13:58 -0500 Subject: [PATCH 335/567] Add all missing fields --- tableauserverclient/server/request_options.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 796f8add3..95233f8fc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -37,35 +37,75 @@ class Operator: class Field: Args = "args" + AuthenticationType = "authenticationType" + Caption = "caption" + Channel = "channel" CompletedAt = "completedAt" + ConnectedWorkbookType = "connectedWorkbookType" + ConnectionTo = "connectionTo" + ConnectionType = "connectionType" ContentUrl = "contentUrl" CreatedAt = "createdAt" + DatabaseName = "databaseName" + DatabaseUserName = "databaseUserName" + Description = "description" + DisplayTabs = "displayTabs" DomainName = "domainName" DomainNickname = "domainNickname" + FavoritesTotal = "favoritesTotal" + Fields = "fields" + FlowId = "flowId" + FriendlyName = "friendlyName" + HasAlert = "hasAlert" + HasAlerts = "hasAlerts" + HasEmbeddedPassword = "hasEmbeddedPassword" + HasExtracts = "hasExtracts" HitsTotal = "hitsTotal" + Id = "id" + IsCertified = "isCertified" + IsConnectable = "isConnectable" + IsDefaultPort = "isDefaultPort" + IsHierarchical = "isHierarchical" IsLocal = "isLocal" + IsPublished = "isPublished" JobType = "jobType" LastLogin = "lastLogin" + Luid = "luid" MinimumSiteRole = "minimumSiteRole" Name = "name" Notes = "notes" + NotificationType = "notificationType" OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" OwnerName = "ownerName" ParentProjectId = "parentProjectId" + Priority = "priority" Progress = "progress" + ProjectId = "projectId" ProjectName = "projectName" PublishSamples = "publishSamples" + ServerName = "serverName" + ServerPort = "serverPort" + SheetCount = "sheetCount" + SheetNumber = "sheetNumber" + SheetType = "sheetType" SiteRole = "siteRole" + Size = "size" StartedAt = "startedAt" Status = "status" + SubscriptionsTotal = "subscriptionsTotal" Subtitle = "subtitle" + TableName = "tableName" Tags = "tags" Title = "title" TopLevelProject = "topLevelProject" Type = "type" UpdatedAt = "updatedAt" UserCount = "userCount" + UserId = "userId" + ViewUrlName = "viewUrlName" + WorkbookDescription = "workbookDescription" + WorkbookName = "workbookName" class Direction: Desc = "desc" From 613334bed02ea2ab79a06d663f402a9af252f81f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:28:26 -0500 Subject: [PATCH 336/567] Make imports absolute --- tableauserverclient/models/task_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 159869b07..2199861c7 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .schedule_item import ScheduleItem -from .target import Target +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.target import Target class TaskItem(object): From 20824143c79258994286d9351a7501b05ad4d0e9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:41:30 -0500 Subject: [PATCH 337/567] Add types to TaskItem --- tableauserverclient/models/task_item.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 2199861c7..24d76fc19 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,3 +1,5 @@ +from typing import List + from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime @@ -44,7 +46,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) @@ -94,7 +96,7 @@ def _parse_element(cls, element, ns): ) @staticmethod - def _translate_task_type(task_type): + def _translate_task_type(task_type: str) -> str: if task_type in TaskItem._TASK_TYPE_MAPPING: return TaskItem._TASK_TYPE_MAPPING[task_type] else: From 0a720e92cd09ed485855064d0c1d04c24685875b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 21:43:42 -0500 Subject: [PATCH 338/567] Make Tasks endpoint imports absolute --- tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 092597388..0d4b23027 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,7 +1,7 @@ import logging -from .endpoint import Endpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint import Endpoint, api +from tableauserverclient.server.exceptions import MissingRequiredFieldError from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory From cdbaf98f4803e48ff77c7fa7a5d72c8f9ee10623 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:14:01 -0500 Subject: [PATCH 339/567] Add task test asset --- test/assets/tasks_without_schedule.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 test/assets/tasks_without_schedule.xml diff --git a/test/assets/tasks_without_schedule.xml b/test/assets/tasks_without_schedule.xml new file mode 100644 index 000000000..e669bf67f --- /dev/null +++ b/test/assets/tasks_without_schedule.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file From e65ca391fd929572ac5d91bd8de31fc7929460a1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:15:01 -0500 Subject: [PATCH 340/567] More typing of TaskItem --- tableauserverclient/models/task_item.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 24d76fc19..96718f6d2 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,4 +1,5 @@ -from typing import List +from datetime import datetime +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -21,14 +22,14 @@ class Type: def __init__( self, - id_, - task_type, - priority, - consecutive_failed_count=0, - schedule_id=None, - schedule_item=None, - last_run_at=None, - target=None, + id_: str, + task_type: str, + priority: int, + consecutive_failed_count: int = 0, + schedule_id: Optional[str] = None, + schedule_item: Optional[str] = None, + last_run_at: Optional[datetime]=None, + target: Optional[Target] = None, ): self.id = id_ self.task_type = task_type @@ -39,7 +40,7 @@ def __init__( self.last_run_at = last_run_at self.target = target - def __repr__(self): + def __repr__(self) -> str: return ( "".format(**self.__dict__) From 600a0b7208392d177ce71072c12e2415b9b8aded Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:15:39 -0500 Subject: [PATCH 341/567] Permit missing tasks missing schedule --- tableauserverclient/models/task_item.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 96718f6d2..eae5948e3 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -65,8 +65,7 @@ def _parse_element(cls, element, ns): last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns) schedule_item_list = ScheduleItem.from_element(element, ns) - if len(schedule_item_list) >= 1: - schedule_item = schedule_item_list[0] + schedule_item = next(iter(schedule_item_list), None) # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource @@ -90,7 +89,7 @@ def _parse_element(cls, element, ns): task_type, priority, consecutive_failed_count, - schedule_item.id, + schedule_item.id if schedule_item is not None else None, schedule_item, last_run_at, target, From b44d69e484abe61b8dde66b402e1b4152f65ce8b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:16:40 -0500 Subject: [PATCH 342/567] Fix import references --- tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 0d4b23027..92e0095c9 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,7 +1,7 @@ import logging -from tableauserverclient.server.endpoint import Endpoint, api -from tableauserverclient.server.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import TaskItem, PaginationItem from tableauserverclient.server import RequestFactory From f4280318ec8d31bdd2cc3347ae632ceb827a5b30 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:17:15 -0500 Subject: [PATCH 343/567] Add test for missing schedule --- test/test_task.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/test_task.py b/test/test_task.py index 4eb2c02e2..4e0157dfd 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,6 +1,7 @@ import os import unittest from datetime import time +from pathlib import Path import requests_mock @@ -8,7 +9,7 @@ from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.task_item import TaskItem -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml") GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") @@ -17,6 +18,7 @@ GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") +GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" class TaskTests(unittest.TestCase): @@ -86,6 +88,15 @@ def test_get_task_with_schedule(self): self.assertEqual("workbook", task.target.type) self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) + def test_get_task_without_schedule(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text()) + all_tasks, pagination_item = self.server.tasks.get() + + task = all_tasks[0] + self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) + self.assertEqual("datasource", task.target.type) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) From 95d66973d8cc8599e71431f297b8838ae556c3a9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:17:37 -0500 Subject: [PATCH 344/567] Formatting --- tableauserverclient/models/task_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index eae5948e3..cb7eeec6f 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -28,7 +28,7 @@ def __init__( consecutive_failed_count: int = 0, schedule_id: Optional[str] = None, schedule_item: Optional[str] = None, - last_run_at: Optional[datetime]=None, + last_run_at: Optional[datetime] = None, target: Optional[Target] = None, ): self.id = id_ From 82ff83aca821b02091fa0035847eb179e83d607b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:39:46 -0500 Subject: [PATCH 345/567] Add type annotations --- tableauserverclient/models/task_item.py | 2 +- .../server/endpoint/tasks_endpoint.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index cb7eeec6f..0ffc3bfab 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -27,7 +27,7 @@ def __init__( priority: int, consecutive_failed_count: int = 0, schedule_id: Optional[str] = None, - schedule_item: Optional[str] = None, + schedule_item: Optional[ScheduleItem] = None, last_run_at: Optional[datetime] = None, target: Optional[Target] = None, ): diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 92e0095c9..383f0984e 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -7,13 +8,16 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + class Tasks(Endpoint): @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) - def __normalize_task_type(self, task_type): + def __normalize_task_type(self, task_type: str) -> str: """ The word for extract refresh used in API URL is "extractRefreshes". It is different than the tag "extractRefresh" used in the request body. @@ -24,7 +28,9 @@ def __normalize_task_type(self, task_type): return task_type @api(version="2.6") - def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): + def get( + self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh + ) -> Tuple[List[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") @@ -38,7 +44,7 @@ def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): return all_tasks, pagination_item @api(version="2.6") - def get_by_id(self, task_id): + def get_by_id(self, task_id: str) -> TaskItem: if not task_id: error = "No Task ID provided" raise ValueError(error) @@ -63,7 +69,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: return server_response.content @api(version="2.6") - def run(self, task_item): + def run(self, task_item: TaskItem) -> bytes: if not task_item.id: error = "Task item missing ID." raise MissingRequiredFieldError(error) @@ -79,7 +85,7 @@ def run(self, task_item): # Delete 1 task by id @api(version="3.6") - def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): + def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") From 36a5547617d2f7d53ae4eaf44f7e5116b29a7181 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 14 Oct 2023 22:40:16 -0500 Subject: [PATCH 346/567] Permit creation of tasks without schedules --- tableauserverclient/server/request_factory.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7fb9bf9ed..6316527ec 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1032,6 +1032,16 @@ def run_req(self, xml_request, task_item): def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: extract_element = ET.SubElement(xml_request, "extractRefresh") + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + if extract_item.target is not None: + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + if extract_item.schedule_item is None: + return ET.tostring(xml_request) + # Schedule attributes schedule_element = ET.SubElement(xml_request, "schedule") @@ -1043,17 +1053,11 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") frequency_element.attrib["end"] = str(interval_item.end_time) if hasattr(interval_item, "interval") and interval_item.interval: intervals_element = ET.SubElement(frequency_element, "intervals") - for interval in interval_item._interval_type_pairs(): + for interval in interval_item._interval_type_pairs(): # type: ignore expression, value = interval single_interval_element = ET.SubElement(intervals_element, "interval") single_interval_element.attrib[expression] = value - # Main attributes - extract_element.attrib["type"] = extract_item.task_type - - target_element = ET.SubElement(extract_element, extract_item.target.type) - target_element.attrib["id"] = extract_item.target.id - return ET.tostring(xml_request) From 11656c4955508f44bcdb13a496989e185ab7e5ae Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sun, 15 Oct 2023 20:09:19 -0500 Subject: [PATCH 347/567] Fix logging format --- tableauserverclient/server/endpoint/tasks_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 383f0984e..a727a515f 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -34,7 +34,7 @@ def get( if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") - logger.info("Querying all {} tasks for the site".format(task_type)) + logger.info("Querying all %s tasks for the site", task_type) url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) @@ -48,7 +48,7 @@ def get_by_id(self, task_id: str) -> TaskItem: if not task_id: error = "No Task ID provided" raise ValueError(error) - logger.info("Querying a single task by id ({})".format(task_id)) + logger.info("Querying a single task by id %s", task_id) url = "{}/{}/{}".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), @@ -62,7 +62,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: if not extract_item: error = "No extract refresh provided" raise ValueError(error) - logger.info("Creating an extract refresh ({})".format(extract_item)) + logger.info("Creating an extract refresh %s", extract_item) url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) @@ -94,4 +94,4 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> raise ValueError(error) url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) - logger.info("Deleted single task (ID: {0})".format(task_id)) + logger.info("Deleted single task (ID: %s)", task_id) From 246b44974a328a6088003ba5f2242392efb76e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 15:42:12 +0200 Subject: [PATCH 348/567] issue-1299 set empty async response to None --- tableauserverclient/config.py | 2 +- .../server/endpoint/endpoint.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 67a77f479..1a4a7dc37 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -7,7 +7,7 @@ # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 -DELAY_SLEEP_SECONDS = 10 +DELAY_SLEEP_SECONDS = 0.1 # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c11a3fb27..5d84d8e7f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -76,7 +76,7 @@ def set_user_agent(parameters): # return explicitly for testing only return parameters - def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: + def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: self.async_response = None response = None logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) @@ -96,32 +96,31 @@ def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]: def send_request_while_show_progress_threaded( self, method, url, parameters={}, request_timeout=0 - ) -> Optional["Response"]: + ) -> Optional[Union["Response", Exception]]: try: request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) - request_thread.async_response = -1 # type:ignore # this is an invented attribute for thread comms request_thread.start() except Exception as e: logger.debug("Error starting server request on separate thread: {}".format(e)) return None - seconds = 0 + seconds = 0.05 minutes = 0 - sleep(1) - if self.async_response != -1: + sleep(seconds) + if self.async_response is not None: # a quick return for any immediate responses return self.async_response - while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout): + while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout): self.log_wait_time_then_sleep(minutes, seconds, url) seconds = seconds + DELAY_SLEEP_SECONDS if seconds >= 60: - seconds = 0 - minutes = minutes + 1 + seconds -= 60 + minutes += 1 return self.async_response def log_wait_time_then_sleep(self, minutes, seconds, url): logger.debug("{} Waiting....".format(datetime.timestamp())) if seconds >= 60: # detailed log message ~every minute - if minutes % 5 == 0: + if minutes % 1 == 0: logger.info( "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) ) From 88d46142cc47fca2655c34a1fe856d391f40b8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 15:44:48 +0200 Subject: [PATCH 349/567] issue-1299 remove unused import --- tableauserverclient/server/endpoint/endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 5d84d8e7f..aa22acfb1 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -2,7 +2,6 @@ from time import sleep from tableauserverclient import datetime_helpers as datetime -import requests from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError From 3ff3131d95c535f1c1e37615d880c2dec0ab433e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Thu, 19 Oct 2023 16:10:49 +0200 Subject: [PATCH 350/567] issue-1299 fix timeout missed when longer than 60s --- .../server/endpoint/endpoint.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index aa22acfb1..8e02933ca 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -94,7 +94,7 @@ def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Respo return self.async_response def send_request_while_show_progress_threaded( - self, method, url, parameters={}, request_timeout=0 + self, method, url, parameters={}, request_timeout=None ) -> Optional[Union["Response", Exception]]: try: request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) @@ -104,28 +104,29 @@ def send_request_while_show_progress_threaded( return None seconds = 0.05 minutes = 0 + last_log_minute = 0 sleep(seconds) if self.async_response is not None: # a quick return for any immediate responses return self.async_response - while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout): - self.log_wait_time_then_sleep(minutes, seconds, url) + timed_out: bool = (request_timeout is not None and seconds > request_timeout) + while (self.async_response is None) and not timed_out: + sleep(DELAY_SLEEP_SECONDS) seconds = seconds + DELAY_SLEEP_SECONDS - if seconds >= 60: - seconds -= 60 - minutes += 1 + minutes = int(seconds/60) + last_log_minute = self.log_wait_time(minutes, last_log_minute, url) return self.async_response - def log_wait_time_then_sleep(self, minutes, seconds, url): + def log_wait_time(self, minutes, last_log_minute, url) -> int: logger.debug("{} Waiting....".format(datetime.timestamp())) - if seconds >= 60: # detailed log message ~every minute - if minutes % 1 == 0: - logger.info( - "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) - ) - else: - logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) - sleep(DELAY_SLEEP_SECONDS) + if minutes > last_log_minute: # detailed log message ~every minute + logger.info( + "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) + ) + last_log_minute = minutes + else: + logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) + return last_log_minute def _make_request( self, From f7d60f94ec7ee3171e649169c1c4a9f4b4cb729f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Fri, 20 Oct 2023 15:48:00 +0200 Subject: [PATCH 351/567] issue-1299 paint it black --- tableauserverclient/server/endpoint/endpoint.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 8e02933ca..5dbf3c9b8 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -109,21 +109,19 @@ def send_request_while_show_progress_threaded( if self.async_response is not None: # a quick return for any immediate responses return self.async_response - timed_out: bool = (request_timeout is not None and seconds > request_timeout) + timed_out: bool = request_timeout is not None and seconds > request_timeout while (self.async_response is None) and not timed_out: sleep(DELAY_SLEEP_SECONDS) seconds = seconds + DELAY_SLEEP_SECONDS - minutes = int(seconds/60) + minutes = int(seconds / 60) last_log_minute = self.log_wait_time(minutes, last_log_minute, url) return self.async_response def log_wait_time(self, minutes, last_log_minute, url) -> int: logger.debug("{} Waiting....".format(datetime.timestamp())) if minutes > last_log_minute: # detailed log message ~every minute - logger.info( - "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url) - ) - last_log_minute = minutes + logger.info("[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)) + last_log_minute = minutes else: logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) return last_log_minute From 538324e8bab057394305f61e6e57dd2f474de0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Wed, 25 Oct 2023 15:49:03 +0200 Subject: [PATCH 352/567] issue-1299 raise exception when returned from blocking request --- tableauserverclient/server/endpoint/endpoint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 5dbf3c9b8..c97091d98 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -148,7 +148,7 @@ def _make_request( # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional["Response"] = self.send_request_while_show_progress_threaded( + server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) @@ -160,6 +160,8 @@ def _make_request( if server_response is None: logger.debug("[{}] Request failed".format(datetime.timestamp())) raise RuntimeError + if isinstance(server_response, Exception): + raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) From 5653a3eabf4beaf0512521745afbdb6f5314cf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= Date: Wed, 15 Nov 2023 18:49:56 +0100 Subject: [PATCH 353/567] issue-1299 black line length 120 --- tableauserverclient/server/endpoint/endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index c97091d98..77a771288 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -148,7 +148,7 @@ def _make_request( # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded( + server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) From 1f9088f7637b46214fd98c2db43249d38c7d66c4 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Dec 2023 19:43:20 -0600 Subject: [PATCH 354/567] fix: correct type hint on download_revision revision_number --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 3c8efbe3b..dbcc1ec53 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -455,7 +455,7 @@ def _get_workbook_revisions( def download_revision( self, workbook_id: str, - revision_number: str, + revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, no_extract: Optional[bool] = None, From f42948a1bee9e7f122764ecc2c9cf1c9d6877ea1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:01:37 -0600 Subject: [PATCH 355/567] fix: handle filename* in download response --- .gitignore | 2 ++ tableauserverclient/helpers/headers.py | 19 +++++++++++++++++++ .../server/endpoint/datasources_endpoint.py | 3 +++ .../server/endpoint/flows_endpoint.py | 3 +++ .../server/endpoint/workbooks_endpoint.py | 3 +++ test/test_datasource.py | 14 ++++++++++++++ test/test_flow.py | 15 +++++++++++++++ test/test_workbook.py | 14 ++++++++++++++ 8 files changed, 73 insertions(+) create mode 100644 tableauserverclient/helpers/headers.py diff --git a/.gitignore b/.gitignore index f0226c065..e9bd2b49f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ var/ *.egg-info/ .installed.cfg *.egg +pip-wheel-metadata/ # PyInstaller # Usually these files are written by a python script from a template @@ -89,6 +90,7 @@ env.py # virtualenv venv/ ENV/ +.venv/ # Spyder project settings .spyderproject diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py new file mode 100644 index 000000000..18b4eacd6 --- /dev/null +++ b/tableauserverclient/helpers/headers.py @@ -0,0 +1,19 @@ +from copy import deepcopy +from typing import Any, Generic, Mapping, Optional, TypeVar, Union +from urllib.parse import unquote_plus + +T = TypeVar("T", ) + +def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: + if "filename*" not in params: + return params + + params = deepcopy(params) + filename = params["filename*"] + prefix = "UTF-8''" + if filename.startswith(prefix): + filename = filename[len(prefix):] + + params["filename"] = unquote_plus(filename) + del params["filename*"] + return params \ No newline at end of file diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index c60f8f919..66ad9f710 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union +from tableauserverclient.helpers.headers import fix_filename + if TYPE_CHECKING: from tableauserverclient.server import Server from tableauserverclient.models import PermissionsRule @@ -441,6 +443,7 @@ def download_revision( filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index ba8a152d7..21c16b1cc 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from tableauserverclient.helpers.headers import fix_filename + from .dqw_endpoint import _DataQualityWarningEndpoint from .endpoint import QuerysetEndpoint, api from .exceptions import InternalServerError, MissingRequiredFieldError @@ -124,6 +126,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dbcc1ec53..506fe02c2 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -6,6 +6,8 @@ from contextlib import closing from pathlib import Path +from tableauserverclient.helpers.headers import fix_filename + from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint @@ -487,6 +489,7 @@ def download_revision( filepath.write(chunk) return_path = filepath else: + params = fix_filename(params) filename = to_filename(os.path.basename(params["filename"])) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index e299e5291..c79bf45fd 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -696,3 +696,17 @@ def test_download_revision(self) -> None: ) file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) self.assertTrue(os.path.exists(file_path)) + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"''' + } + ) + file_path = self.server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) \ No newline at end of file diff --git a/test/test_flow.py b/test/test_flow.py index d10641809..d7fa2dbc3 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,5 +1,6 @@ import os import requests_mock +import tempfile import unittest from io import BytesIO @@ -203,3 +204,17 @@ def test_refresh(self): self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6") self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"''' + } + ) + file_path = self.server.flows.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook.py b/test/test_workbook.py index 5114ce1b8..9804b2c02 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -932,3 +932,17 @@ def test_download_revision(self) -> None: ) file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) self.assertTrue(os.path.exists(file_path)) + + def test_bad_download_response(self) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={ + "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"''' + } + ) + file_path = self.server.workbooks.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", + td + ) + self.assertTrue(os.path.exists(file_path)) From 76559d4c0456034a610818fd3bace51067e7ba07 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:06:05 -0600 Subject: [PATCH 356/567] style: black formatting --- tableauserverclient/helpers/headers.py | 11 +++++++---- test/test_datasource.py | 9 +++------ test/test_flow.py | 9 ++------- test/test_workbook.py | 9 ++------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py index 18b4eacd6..57be21b23 100644 --- a/tableauserverclient/helpers/headers.py +++ b/tableauserverclient/helpers/headers.py @@ -2,18 +2,21 @@ from typing import Any, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import unquote_plus -T = TypeVar("T", ) +T = TypeVar( + "T", +) + def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: if "filename*" not in params: return params - + params = deepcopy(params) filename = params["filename*"] prefix = "UTF-8''" if filename.startswith(prefix): - filename = filename[len(prefix):] + filename = filename[len(prefix) :] params["filename"] = unquote_plus(filename) del params["filename*"] - return params \ No newline at end of file + return params diff --git a/test/test_datasource.py b/test/test_datasource.py index c79bf45fd..f258fdc52 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -703,10 +703,7 @@ def test_bad_download_response(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", headers={ "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"''' - } - ) - file_path = self.server.datasources.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + }, ) - self.assertTrue(os.path.exists(file_path)) \ No newline at end of file + file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_flow.py b/test/test_flow.py index d7fa2dbc3..a90b18171 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -209,12 +209,7 @@ def test_bad_download_response(self) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={ - "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"''' - } - ) - file_path = self.server.flows.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, ) + file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) diff --git a/test/test_workbook.py b/test/test_workbook.py index 9804b2c02..212d55a37 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -937,12 +937,7 @@ def test_bad_download_response(self) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={ - "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"''' - } - ) - file_path = self.server.workbooks.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", - td + headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''}, ) + file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) From 19a9f51ab7ab65a1819bfabf28f885d2fe0df7e2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Dec 2023 22:12:15 -0600 Subject: [PATCH 357/567] fix: strip typing from fix_filename --- tableauserverclient/helpers/headers.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py index 57be21b23..2ed4a814d 100644 --- a/tableauserverclient/helpers/headers.py +++ b/tableauserverclient/helpers/headers.py @@ -1,13 +1,8 @@ from copy import deepcopy -from typing import Any, Generic, Mapping, Optional, TypeVar, Union from urllib.parse import unquote_plus -T = TypeVar( - "T", -) - -def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]: +def fix_filename(params): if "filename*" not in params: return params From f17a75d14cc0d516a01ec2676326d42f29a1866c Mon Sep 17 00:00:00 2001 From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Date: Wed, 13 Dec 2023 10:49:52 -0800 Subject: [PATCH 358/567] add support for multiple intervals for hourly, daily, and monthly schedules --- tableauserverclient/models/interval_item.py | 124 +++++++++++++++----- tableauserverclient/models/schedule_item.py | 36 ++++-- test/assets/schedule_get_daily_id.xml | 11 ++ test/assets/schedule_get_hourly_id.xml | 11 ++ test/assets/schedule_get_monthly_id.xml | 11 ++ test/test_schedule.py | 52 +++++++- 6 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 test/assets/schedule_get_daily_id.xml create mode 100644 test/assets/schedule_get_hourly_id.xml create mode 100644 test/assets/schedule_get_monthly_id.xml diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 25b6d09d7..44c24a6f6 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -29,7 +29,12 @@ class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time - self.interval = interval_value + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) @property def _frequency(self): @@ -60,25 +65,44 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): + def interval(self, intervals): VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} - if float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) - raise ValueError(error) + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) - self._interval = interval + self._interval = intervals def _interval_type_pairs(self): - # We use fractional hours for the two minute-based intervals. - # Need to convert to minutes from hours here - if self.interval in {0.25, 0.5}: - calculated_interval = int(self.interval * 60) - interval_type = IntervalItem.Occurrence.Minutes - else: - calculated_interval = self.interval - interval_type = IntervalItem.Occurrence.Hours + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours - return [(interval_type, str(calculated_interval))] + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class DailyInterval(object): @@ -105,8 +129,45 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): - self._interval = interval + def interval(self, intervals): + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) + + self._interval = intervals + + def _interval_type_pairs(self): + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours + + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class WeeklyInterval(object): @@ -146,7 +207,12 @@ def _interval_type_pairs(self): class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time - self.interval = str(interval_value) + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) @property def _frequency(self): @@ -167,24 +233,24 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval_value): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - + def interval(self, interval_values): # This is weird because the value could be a str or an int # The only valid str is 'LastDay' so we check that first. If that's not it # try to convert it to an int, if that fails because it's an incorrect string # like 'badstring' we catch and re-raise. Otherwise we convert to int and check # that it's in range 1-31 + for interval_value in interval_values: + error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value != "LastDay": + try: + if not (1 <= int(interval_value) <= 31): + raise ValueError(error) + except ValueError: + if interval_value != "LastDay": + raise ValueError(error) - self._interval = str(interval_value) + self._interval = interval_values def _interval_type_pairs(self): return [(IntervalItem.Occurrence.MonthDay, self.interval)] diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index edfd0fe70..23796ff46 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -251,25 +251,43 @@ def _parse_interval_item(parsed_response, frequency, ns): interval.extend(interval_elem.attrib.items()) if frequency == IntervalItem.Frequency.Daily: - return DailyInterval(start_time) + converted_intervals = [] + + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(float(i[1])) + else: + converted_intervals.append(i[1]) + + return DailyInterval(start_time, *converted_intervals) if frequency == IntervalItem.Frequency.Hourly: - interval_occurrence, interval_value = interval.pop() + converted_intervals = [] - # We use fractional hours for the two minute-based intervals. - # Need to convert to hours from minutes here - if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value) / 60 + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(i[1]) + else: + converted_intervals.append(i[1]) - return HourlyInterval(start_time, end_time, interval_value) + return HourlyInterval(start_time, end_time, tuple(converted_intervals)) if frequency == IntervalItem.Frequency.Weekly: interval_values = [i[1] for i in interval] return WeeklyInterval(start_time, *interval_values) if frequency == IntervalItem.Frequency.Monthly: - interval_occurrence, interval_value = interval.pop() - return MonthlyInterval(start_time, interval_value) + interval_values = [i[1] for i in interval] + + return MonthlyInterval(start_time, tuple(interval_values)) @staticmethod def _parse_element(schedule_xml, ns): diff --git a/test/assets/schedule_get_daily_id.xml b/test/assets/schedule_get_daily_id.xml new file mode 100644 index 000000000..99467a391 --- /dev/null +++ b/test/assets/schedule_get_daily_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_hourly_id.xml b/test/assets/schedule_get_hourly_id.xml new file mode 100644 index 000000000..27c374ccf --- /dev/null +++ b/test/assets/schedule_get_hourly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_monthly_id.xml b/test/assets/schedule_get_monthly_id.xml new file mode 100644 index 000000000..3fc32cc57 --- /dev/null +++ b/test/assets/schedule_get_monthly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 807467918..76c8720b9 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -11,6 +11,9 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") +GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") +GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") +GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -100,6 +103,51 @@ def test_get_by_id(self) -> None: self.assertEqual("Weekday early mornings", schedule.name) self.assertEqual("Active", schedule.state) + def test_get_hourly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_HOURLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Hourly schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 0.5), schedule.interval_item.interval) + + def test_get_daily_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_DAILY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Daily schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 2.0), schedule.interval_item.interval) + + def test_get_monthly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_MONTHLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly multiple days", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) @@ -131,7 +179,7 @@ def test_create_hourly(self) -> None: self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] - self.assertEqual("8", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("8",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_create_daily(self) -> None: with open(CREATE_DAILY_XML, "rb") as f: @@ -216,7 +264,7 @@ def test_create_monthly(self) -> None: self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual("12", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("12",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: From bbb45d427405b4f024708892c5819b3247bc00c3 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 16 Jan 2024 12:35:17 -0800 Subject: [PATCH 359/567] Update all action versions --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 4 ++-- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/pypi-smoke-tests.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- .github/workflows/slack.yml | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index e153c1fc7..70bc845e9 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 7d6cd068a..41a944e63 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 330bfe7d3..cae0f409c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,10 +14,10 @@ jobs: name: Build dist files for PyPi runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.9 - name: Build dist files diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml index eb6406573..45ea94400 100644 --- a/.github/workflows/pypi-smoke-tests.yml +++ b/.github/workflows/pypi-smoke-tests.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: pip install diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3df497806..6b1629bfd 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index b11f4009a..2ecb0be7f 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Send message to Slack API continue-on-error: true - uses: archive/github-actions-slack@v2.2.2 + uses: archive/github-actions-slack@v2.8.0 id: notify with: slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} From 21503f4fe160721c1e3c7bfded7d9110bda8360a Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:09:13 -0800 Subject: [PATCH 360/567] remove threading code --- .../server/endpoint/endpoint.py | 43 ++----------------- test/test_endpoint.py | 13 +++--- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 77a771288..2b7f57069 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,5 +1,3 @@ -from threading import Thread -from time import sleep from tableauserverclient import datetime_helpers as datetime from packaging.version import Version @@ -76,55 +74,20 @@ def set_user_agent(parameters): return parameters def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: - self.async_response = None response = None logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) try: response = method(url, **parameters) - self.async_response = response logger.debug("[{}] Call finished".format(datetime.timestamp())) except Exception as e: logger.debug("Error making request to server: {}".format(e)) - self.async_response = e - finally: - if response and not self.async_response: - logger.debug("Request response not saved") - return None - logger.debug("[{}] Request complete".format(datetime.timestamp())) - return self.async_response + raise e + return response def send_request_while_show_progress_threaded( self, method, url, parameters={}, request_timeout=None ) -> Optional[Union["Response", Exception]]: - try: - request_thread = Thread(target=self._blocking_request, args=(method, url, parameters)) - request_thread.start() - except Exception as e: - logger.debug("Error starting server request on separate thread: {}".format(e)) - return None - seconds = 0.05 - minutes = 0 - last_log_minute = 0 - sleep(seconds) - if self.async_response is not None: - # a quick return for any immediate responses - return self.async_response - timed_out: bool = request_timeout is not None and seconds > request_timeout - while (self.async_response is None) and not timed_out: - sleep(DELAY_SLEEP_SECONDS) - seconds = seconds + DELAY_SLEEP_SECONDS - minutes = int(seconds / 60) - last_log_minute = self.log_wait_time(minutes, last_log_minute, url) - return self.async_response - - def log_wait_time(self, minutes, last_log_minute, url) -> int: - logger.debug("{} Waiting....".format(datetime.timestamp())) - if minutes > last_log_minute: # detailed log message ~every minute - logger.info("[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)) - last_log_minute = minutes - else: - logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url)) - return last_log_minute + return self._blocking_request(method, url, parameters) def _make_request( self, diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 3d2d1c995..8635af978 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -1,4 +1,6 @@ from pathlib import Path +import pytest +import requests import unittest import tableauserverclient as TSC @@ -35,11 +37,12 @@ def test_user_friendly_request_returns(self) -> None: ) self.assertIsNotNone(response) - def test_blocking_request_returns(self) -> None: - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) - self.assertIsNotNone(response) + def test_blocking_request_raises_request_error(self) -> None: + with pytest.raises(requests.exceptions.ConnectionError): + url = "http://test/" + endpoint = TSC.server.Endpoint(self.server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + self.assertIsNotNone(response) def test_get_request_stream(self) -> None: url = "http://test/" From 65f84768dc525639dee7fd9496e4a3d2710ea25f Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:11:39 -0800 Subject: [PATCH 361/567] fix basic sample 1. remove metrics since feature is disabled 2. update VALID_INTERVALS to include all values returned from the server --- samples/getting_started/3_hello_universe.py | 11 +++-------- tableauserverclient/models/interval_item.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 3ed39fd17..077785317 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -62,11 +62,6 @@ def main(): print("{} jobs".format(pagination.total_available)) print(jobs[0]) - metrics, pagination = server.metrics.get() - if metrics: - print("{} metrics".format(pagination.total_available)) - print(metrics[0]) - schedules, pagination = server.schedules.get() if schedules: print("{} schedules".format(pagination.total_available)) @@ -82,7 +77,7 @@ def main(): print("{} webhooks".format(pagination.total_available)) print(webhooks[0]) - users, pagination = server.metrics.get() + users, pagination = server.users.get() if users: print("{} users".format(pagination.total_available)) print(users[0]) @@ -92,5 +87,5 @@ def main(): print("{} groups".format(pagination.total_available)) print(groups[0]) - if __name__ == "__main__": - main() +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index f2f159625..537e6c14f 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -69,7 +69,7 @@ def interval(self): @interval.setter def interval(self, intervals): - VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24} for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): From e9a41386dec058f7f36f8cdfe673aba0940c9b86 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jan 2024 01:11:51 -0800 Subject: [PATCH 362/567] format --- .gitignore | 1 + contributing.md | 3 +-- samples/getting_started/3_hello_universe.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e9bd2b49f..92778cd81 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,4 @@ $RECYCLE.BIN/ docs/_site/ docs/.jekyll-metadata docs/Gemfile.lock +samples/credentials diff --git a/contributing.md b/contributing.md index 41c339cb6..6404611a9 100644 --- a/contributing.md +++ b/contributing.md @@ -10,8 +10,7 @@ Contribution can include, but are not limited to, any of the following: * Fix an Issue/Bug * Add/Fix documentation -Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting -a feature do not require the CLA. +Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting a feature do not require the CLA. ## Issues and Feature Requests diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 077785317..21de97831 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -87,5 +87,6 @@ def main(): print("{} groups".format(pagination.total_available)) print(groups[0]) + if __name__ == "__main__": main() From 1d3a642fea206108d540ea2fa9a87510af26f363 Mon Sep 17 00:00:00 2001 From: markm Date: Fri, 19 Jan 2024 12:27:31 -0600 Subject: [PATCH 363/567] Changes to alter cgi dependency to email.Messages --- .../server/endpoint/datasources_endpoint.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 66ad9f710..be3733d67 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import json import io @@ -437,14 +437,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB From 2cc51710d62ceebddd89ee1f34fcb66948f7af1c Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:33:52 -0600 Subject: [PATCH 364/567] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/flows_endpoint.py | 8 +++++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 21c16b1cc..a9b937ea5 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -120,14 +120,16 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 506fe02c2..73f69a145 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -483,14 +483,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB From 999b3019a50f8860168b276e86b30cc925080c89 Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:34:44 -0600 Subject: [PATCH 365/567] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- tableauserverclient/server/endpoint/flows_endpoint.py | 2 +- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index be3733d67..7a797cf4c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -438,7 +438,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index a9b937ea5..5132ee454 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -121,7 +121,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 73f69a145..58fa4fe98 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -484,7 +484,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB From 68b915774737083ecad5f365f1946a4ef778050f Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:44:20 -0600 Subject: [PATCH 366/567] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- tableauserverclient/server/endpoint/flows_endpoint.py | 2 +- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7a797cf4c..28226d280 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -439,7 +439,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 5132ee454..77b01c478 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -122,7 +122,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 58fa4fe98..393a028c8 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -485,7 +485,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) From 5611859114abb76b2ef921330980d73b6d2c9b7d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 18 Jan 2024 22:16:14 -0600 Subject: [PATCH 367/567] feat: allow viz height and width parameters --- .../models/property_decorators.py | 8 +++-- tableauserverclient/server/request_options.py | 33 ++++++++++++++++++- test/test_view.py | 29 ++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 7c801a4b5..6ffcf6f85 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,8 @@ +from collections.abc import Container import datetime import re from functools import wraps +from typing import Any, Optional from tableauserverclient.datetime_helpers import parse_datetime @@ -65,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range, allowed=None): +def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -89,8 +91,10 @@ def wrapper(self, value): raise ValueError(error) min, max = range + if value in allowed: + return func(self, value) - if (value < min or value > max) and (value not in allowed): + if value < min or value > max: raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 95233f8fc..f2bd3c939 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,5 @@ +import sys + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -261,11 +263,13 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None, maxage=-1): + def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation self.max_age = maxage + self.viz_height = viz_height + self.viz_width = viz_width @property def max_age(self): @@ -276,6 +280,24 @@ def max_age(self): def max_age(self, value): self._max_age = value + @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): params = {} if self.page_type: @@ -287,6 +309,15 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + 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 + self._append_view_filters(params) return params diff --git a/test/test_view.py b/test/test_view.py index 1459150bb..720a0ce64 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -315,3 +315,32 @@ def test_filter_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(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, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.views.populate_pdf(single_view, req_option) + self.assertEqual(response, single_view.pdf) + + def test_pdf_errors(self) -> None: + req_option = TSC.PDFRequestOptions(viz_height=1080) + with self.assertRaises(ValueError): + req_option.get_query_params() + req_option = TSC.PDFRequestOptions(viz_width=1920) + with self.assertRaises(ValueError): + req_option.get_query_params() From 8ad3c03b89a3851a780dc57bd7e5a4f2970c608c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:04:14 -0600 Subject: [PATCH 368/567] fix: use python3.8 syntax --- tableauserverclient/models/property_decorators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 6ffcf6f85..ea781cd51 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,7 +2,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional +from typing import Any, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -67,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. From 7e44b5ec47b777cd43e2725be2019892d6e4d31a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:06:43 -0600 Subject: [PATCH 369/567] fix: python3.8 syntax --- tableauserverclient/models/property_decorators.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ea781cd51..58c33699b 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,8 +1,7 @@ -from collections.abc import Container import datetime import re from functools import wraps -from typing import Any, Optional, Tuple +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime From ffd0b8fd8452ec8fcaf78a03a838d8670256ba02 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jan 2024 07:30:24 -0600 Subject: [PATCH 370/567] docs: comment PDF viz dimensions XOR --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f2bd3c939..8304b8f68 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -309,6 +309,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + # 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") From 9ddbad56b8f9fff464f25f8262d97d01e67a8563 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 1 Feb 2024 15:58:16 -0800 Subject: [PATCH 371/567] Add support for System schedule type I'm not fully clear on where these might come from, but this change should let TSC work in such cases. Fixes #1349 --- tableauserverclient/models/schedule_item.py | 1 + test/assets/schedule_get.xml | 1 + test/test_schedule.py | 10 ++++++++++ 3 files changed, 12 insertions(+) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index db187a5f9..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -26,6 +26,7 @@ class Type: Subscription = "Subscription" DataAcceleration = "DataAcceleration" ActiveDirectorySync = "ActiveDirectorySync" + System = "System" class ExecutionOrder: Parallel = "Parallel" diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml index 66e4d6e51..db5e1a05e 100644 --- a/test/assets/schedule_get.xml +++ b/test/assets/schedule_get.xml @@ -5,5 +5,6 @@ + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 76c8720b9..3bbf5709b 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -50,6 +50,7 @@ def test_get(self) -> None: extract = all_schedules[0] subscription = all_schedules[1] flow = all_schedules[2] + system = all_schedules[3] self.assertEqual(2, pagination_item.total_available) self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) @@ -79,6 +80,15 @@ def test_get(self) -> None: self.assertEqual("Flow", flow.schedule_type) self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) + self.assertEqual("3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04", system.id) + self.assertEqual("First of the month 2:00AM", system.name) + self.assertEqual("Active", system.state) + self.assertEqual(30, system.priority) + self.assertEqual("2019-02-19T18:52:19Z", format_datetime(system.created_at)) + self.assertEqual("2019-02-19T18:55:51Z", format_datetime(system.updated_at)) + self.assertEqual("System", system.schedule_type) + self.assertEqual("2019-03-01T09:00:00Z", format_datetime(system.next_run_at)) + def test_get_empty(self) -> None: with open(GET_EMPTY_XML, "rb") as f: response_xml = f.read().decode("utf-8") From 60fa87f07d54cdc635c06614a9f0455675bbb973 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Feb 2024 20:18:21 -0800 Subject: [PATCH 372/567] Add failing test retrieving a task with 24 hour (aka daily) interval --- test/assets/tasks_with_interval.xml | 20 ++++++++++++++++++++ test/test_task.py | 10 ++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/assets/tasks_with_interval.xml diff --git a/test/assets/tasks_with_interval.xml b/test/assets/tasks_with_interval.xml new file mode 100644 index 000000000..a317408fb --- /dev/null +++ b/test/assets/tasks_with_interval.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index 4e0157dfd..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -19,6 +19,7 @@ GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" +GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" class TaskTests(unittest.TestCase): @@ -97,6 +98,15 @@ def test_get_task_without_schedule(self): self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) self.assertEqual("datasource", task.target.type) + def test_get_task_with_interval(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_XML_WITH_INTERVAL.read_text()) + all_tasks, pagination_item = self.server.tasks.get() + + task = all_tasks[0] + self.assertEqual("e4de0575-fcc7-4232-5659-be09bb8e7654", task.target.id) + self.assertEqual("datasource", task.target.type) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) From 0dca1aae66703fb932f364bee9cdd899a9cc51ee Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Feb 2024 22:38:55 -0800 Subject: [PATCH 373/567] Add 24 (hours) as a valid interval which can be returned from the server --- tableauserverclient/models/interval_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 537e6c14f..3ee1fee08 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -136,7 +136,7 @@ def interval(self): @interval.setter def interval(self, intervals): - VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24} for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval From 3cc0f8ee57fcb0d9ffa1adb7bb7b62b70c54e0f5 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 14 Feb 2024 11:17:32 -0800 Subject: [PATCH 374/567] Add Python 3.12 to test matrix --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6b1629bfd..fb89d5de1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} From 0fb214e22b2aac6d4bab54d17a43e80851e66e93 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 14 Feb 2024 11:45:21 -0800 Subject: [PATCH 375/567] Tweak test action to stop double-running everything --- .github/workflows/run-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index fb89d5de1..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,11 @@ name: Python tests -on: [push, pull_request] +on: + pull_request: {} + push: + branches: + - development + - master jobs: build: From 0ddae7ce24c457b522c87867531b91213263f7f1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:15:45 -0600 Subject: [PATCH 376/567] feat: add description support on wb publish --- tableauserverclient/models/workbook_item.py | 4 ++++ tableauserverclient/server/request_factory.py | 3 +++ test/assets/workbook_publish.xml | 4 ++-- test/test_workbook.py | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 86a9a2f18..57ddf83f8 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -91,6 +91,10 @@ def created_at(self) -> Optional[datetime.datetime]: def description(self) -> Optional[str]: return self._description + @description.setter + def description(self, value: str): + self._description = value + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6316527ec..70d2b30fc 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -911,6 +911,9 @@ def _generate_xml( for connection in connections: _add_connections_element(connections_element, connection) + if workbook_item.description is not None: + workbook_element.attrib["description"] = workbook_item.description + if hidden_views is not None: import warnings diff --git a/test/assets/workbook_publish.xml b/test/assets/workbook_publish.xml index dcfc79936..3e23bda71 100644 --- a/test/assets/workbook_publish.xml +++ b/test/assets/workbook_publish.xml @@ -1,6 +1,6 @@ - + @@ -8,4 +8,4 @@ - \ No newline at end of file + diff --git a/test/test_workbook.py b/test/test_workbook.py index 212d55a37..ac3d44b28 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -488,6 +488,8 @@ def test_publish(self) -> None: name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" ) + new_workbook.description = "REST API Testing" + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew @@ -506,6 +508,7 @@ def test_publish(self) -> None: self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) self.assertEqual("GDP per capita", new_workbook.views[0].name) self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + self.assertEqual("REST API Testing", new_workbook.description) def test_publish_a_packaged_file_object(self) -> None: with open(PUBLISH_XML, "rb") as f: From eaedc29fe6a16a2060b3dbe32f9fa047f48b9994 Mon Sep 17 00:00:00 2001 From: ltiffanydev <148500608+ltiffanydev@users.noreply.github.com> Date: Mon, 4 Mar 2024 22:21:39 -0800 Subject: [PATCH 377/567] Add Data Acceleration and Data Freshness Policy support (#1343) * Add data acceleration & data freshness policy functions * Add unit tests and raise errors on missing params * fix types & spell checks * addressed some feedback * addressed feedback * cleanup code * Revert "Merge branch 'add_data_acceleration_and_data_freshness_policy_support' of https://github.com/tableau/server-client-python into add_data_acceleration_and_data_freshness_policy_support" This reverts commit 5b30e57d959ae80b8279d7eeb2e4f374fc111664, reversing changes made to 5789e32bd57f4459209da05003f1ccf4e93e01a1. * fix formatting * Address feedback * mypy & formatting changes --- samples/update_workbook_data_acceleration.py | 109 +++++++++ .../update_workbook_data_freshness_policy.py | 218 ++++++++++++++++++ tableauserverclient/__init__.py | 1 + tableauserverclient/models/__init__.py | 1 + .../models/data_freshness_policy_item.py | 210 +++++++++++++++++ .../models/property_decorators.py | 10 +- tableauserverclient/models/view_item.py | 34 +++ tableauserverclient/models/workbook_item.py | 34 ++- .../server/endpoint/workbooks_endpoint.py | 10 +- tableauserverclient/server/request_factory.py | 58 ++++- ...workbook_get_by_id_acceleration_status.xml | 19 ++ .../workbook_update_acceleration_status.xml | 16 ++ .../workbook_update_data_freshness_policy.xml | 9 + ...workbook_update_data_freshness_policy2.xml | 9 + ...workbook_update_data_freshness_policy3.xml | 11 + ...workbook_update_data_freshness_policy4.xml | 12 + ...workbook_update_data_freshness_policy5.xml | 16 ++ ...workbook_update_data_freshness_policy6.xml | 15 ++ ...kbook_update_views_acceleration_status.xml | 19 ++ test/test_data_freshness_policy.py | 189 +++++++++++++++ test/test_view_acceleration.py | 119 ++++++++++ 21 files changed, 1101 insertions(+), 18 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 samples/update_workbook_data_freshness_policy.py create mode 100644 tableauserverclient/models/data_freshness_policy_item.py create mode 100644 test/assets/workbook_get_by_id_acceleration_status.xml create mode 100644 test/assets/workbook_update_acceleration_status.xml create mode 100644 test/assets/workbook_update_data_freshness_policy.xml create mode 100644 test/assets/workbook_update_data_freshness_policy2.xml create mode 100644 test/assets/workbook_update_data_freshness_policy3.xml create mode 100644 test/assets/workbook_update_data_freshness_policy4.xml create mode 100644 test/assets/workbook_update_data_freshness_policy5.xml create mode 100644 test/assets/workbook_update_data_freshness_policy6.xml create mode 100644 test/assets/workbook_update_views_acceleration_status.xml create mode 100644 test/test_data_freshness_policy.py create mode 100644 test/test_view_acceleration.py diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py new file mode 100644 index 000000000..75f12262f --- /dev/null +++ b/samples/update_workbook_data_acceleration.py @@ -0,0 +1,109 @@ +#### +# This script demonstrates how to update workbook data acceleration using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook to try data acceleration. + # Note that data acceleration has a couple of requirements, please check the Tableau help page + # to verify your workbook/view is eligible for data acceleration. + + # Assuming 1st workbook is eligible for sample purposes + sample_workbook = all_workbooks[2] + + # Enable acceleration for all the views in the workbook + enable_config = dict() + enable_config["acceleration_enabled"] = True + enable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = enable_config + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + # Since we did not set any specific view, we will enable all views in the workbook + print("Enable acceleration for all the views in the workbook " + updated.name + ".") + + # Disable acceleration on one of the view in the workbook + # You have to populate_views first, then set the views of the workbook + # to the ones you want to update. + server.workbooks.populate_views(sample_workbook) + view_to_disable = sample_workbook.views[0] + sample_workbook.views = [view_to_disable] + + disable_config = dict() + disable_config["acceleration_enabled"] = False + disable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = disable_config + # To get the acceleration status on the response, set includeViewAccelerationStatus=true + # Note that you have to populate_views first to get the acceleration status, since + # acceleration status is per view basis (not per workbook) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) + view1 = updated.views[0] + print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") + + # Get acceleration status of the views in workbook using workbooks.get_by_id + # This won't need to do populate_views beforehand + my_workbook = server.workbooks.get_by_id(sample_workbook.id) + view1 = my_workbook.views[0] + view2 = my_workbook.views[1] + print( + "Fetching acceleration status for views in the workbook " + + updated.name + + ".\n" + + 'View "' + + view1.name + + '" has acceleration_status = ' + + view1.data_acceleration_config["acceleration_status"] + + ".\n" + + 'View "' + + view2.name + + '" has acceleration_status = ' + + view2.data_acceleration_config["acceleration_status"] + + "." + ) + + +if __name__ == "__main__": + main() diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py new file mode 100644 index 000000000..9e4d63dc1 --- /dev/null +++ b/samples/update_workbook_data_freshness_policy.py @@ -0,0 +1,218 @@ +#### +# This script demonstrates how to update workbook data freshness policy using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server") + parser.add_argument( + "--token-value", "-v", help="value of the personal access token " "used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook that has live datasource connection. + # Assuming 1st workbook met the criteria for sample purposes + # Data Freshness Policy is not available on extract & file-based datasource. + sample_workbook = all_workbooks[2] + + # Get more info from the workbook selected + # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError + # it could mean the workbook selected does not have live connection, which means it doesn't have + # data freshness policy. Change to another workbook with live datasource connection. + sample_workbook_extended = server.workbooks.get_by_id(sample_workbook.id) + try: + print( + "Workbook " + + sample_workbook.name + + " has data freshness policy option set to: " + + sample_workbook_extended.data_freshness_policy.option + ) + except AttributeError as e: + print( + "Workbook does not have data freshness policy, possibly due to the workbook selected " + "does not have live connection. Change to another workbook using live datasource connection." + ) + + # Update Workbook Data Freshness Policy to "AlwaysLive" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "SiteDefault" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "FreshEvery" schedule. + # Set the schedule to be fresh every 10 hours + # Once the data_freshness_policy is already populated (e.g. due to previous calls), + # it is possible to directly change the option & other parameters directly like below + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshEvery + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + sample_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_every_schedule.value) + + " " + + updated.data_freshness_policy.fresh_every_schedule.frequency + ) + + # Update Workbook Data Freshness Policy to "FreshAt" schedule. + # Set the schedule to be fresh at 10AM every day + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshAt + fresh_at_ten_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "10:00:00", "America/Los_Angeles" + ) + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_ten_daily + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_at_schedule.time) + + " every " + + updated.data_freshness_policy.fresh_at_schedule.frequency + ) + + # Set the schedule to be fresh at 6PM every week on Wednesday and Sunday + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_6pm_wed_sun = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "18:00:00", + "America/Los_Angeles", + [IntervalItem.Day.Wednesday, "Sunday"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_6pm_wed_sun + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + + "," + + new_fresh_at_schedule.interval_item[1] + ) + + # Set the schedule to be fresh at 12AM every last day of the month + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_last_day_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_last_day_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + ) + + # Set the schedule to be fresh at 8PM every 1st,13th,20th day of the month + fresh_at_dates_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, + "00:00:00", + "America/Los_Angeles", + ["1", "13", "20"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_dates_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + str(new_fresh_at_schedule.interval_item) + ) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5c3c1922..f093f521b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -10,6 +10,7 @@ DailyInterval, DataAlertItem, DatabaseItem, + DataFreshnessPolicyItem, DatasourceItem, FavoriteItem, FlowItem, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 03d692583..e7a853d9a 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem from .database_item import DatabaseItem +from .data_freshness_policy_item import DataFreshnessPolicyItem from .datasource_item import DatasourceItem from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py new file mode 100644 index 000000000..f567c501c --- /dev/null +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -0,0 +1,210 @@ +import xml.etree.ElementTree as ET + +from typing import Optional, Union, List +from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable +from .interval_item import IntervalItem + + +class DataFreshnessPolicyItem: + class Option: + AlwaysLive = "AlwaysLive" + SiteDefault = "SiteDefault" + FreshEvery = "FreshEvery" + FreshAt = "FreshAt" + + class FreshEvery: + class Frequency: + Minutes = "Minutes" + Hours = "Hours" + Days = "Days" + Weeks = "Weeks" + + def __init__(self, frequency: str, value: int): + self.frequency: str = frequency + self.value: int = value + + def __repr__(self): + return "".format(**vars(self)) + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_every_schedule_elem: ET.Element): + frequency = fresh_every_schedule_elem.get("frequency", None) + value_str = fresh_every_schedule_elem.get("value", None) + if (frequency is None) or (value_str is None): + return None + value = int(value_str) + return DataFreshnessPolicyItem.FreshEvery(frequency, value) + + class FreshAt: + class Frequency: + Day = "Day" + Week = "Week" + Month = "Month" + + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + self.frequency = frequency + self.time = time + self.timezone = timezone + self.interval_item: Optional[List[str]] = interval_item + + def __repr__(self): + return ( + " timezone={_timezone} " "interval_item={_interval_time}" + ).format(**vars(self)) + + @property + def interval_item(self) -> Optional[List[str]]: + return self._interval_item + + @interval_item.setter + def interval_item(self, value: List[str]): + self._interval_item = value + + @property + def time(self): + return self._time + + @time.setter + @property_not_nullable + def time(self, value): + self._time = value + + @property + def timezone(self) -> str: + return self._timezone + + @timezone.setter + def timezone(self, value: str): + self._timezone = value + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_at_schedule_elem: ET.Element, ns): + frequency = fresh_at_schedule_elem.get("frequency", None) + time = fresh_at_schedule_elem.get("time", None) + if (frequency is None) or (time is None): + return None + timezone = fresh_at_schedule_elem.get("timezone", None) + interval = parse_intervals(fresh_at_schedule_elem, frequency, ns) + return DataFreshnessPolicyItem.FreshAt(frequency, time, timezone, interval) + + def __init__(self, option: str): + self.option = option + self.fresh_every_schedule: Optional[DataFreshnessPolicyItem.FreshEvery] = None + self.fresh_at_schedule: Optional[DataFreshnessPolicyItem.FreshAt] = None + + def __repr__(self): + return "".format(**vars(self)) + + @property + def option(self) -> str: + return self._option + + @option.setter + @property_is_enum(Option) + def option(self, value: str): + self._option = value + + @property + def fresh_every_schedule(self) -> Optional[FreshEvery]: + return self._fresh_every_schedule + + @fresh_every_schedule.setter + def fresh_every_schedule(self, value: FreshEvery): + self._fresh_every_schedule = value + + @property + def fresh_at_schedule(self) -> Optional[FreshAt]: + return self._fresh_at_schedule + + @fresh_at_schedule.setter + def fresh_at_schedule(self, value: FreshAt): + self._fresh_at_schedule = value + + @classmethod + def from_xml_element(cls, data_freshness_policy_elem, ns): + option = data_freshness_policy_elem.get("option", None) + if option is None: + return None + data_freshness_policy = DataFreshnessPolicyItem(option) + + fresh_at_schedule = None + fresh_every_schedule = None + if option == "FreshAt": + fresh_at_schedule_elem = data_freshness_policy_elem.find(".//t:freshAtSchedule", namespaces=ns) + fresh_at_schedule = DataFreshnessPolicyItem.FreshAt.from_xml_element(fresh_at_schedule_elem, ns) + data_freshness_policy.fresh_at_schedule = fresh_at_schedule + elif option == "FreshEvery": + fresh_every_schedule_elem = data_freshness_policy_elem.find(".//t:freshEverySchedule", namespaces=ns) + fresh_every_schedule = DataFreshnessPolicyItem.FreshEvery.from_xml_element(fresh_every_schedule_elem) + data_freshness_policy.fresh_every_schedule = fresh_every_schedule + + return data_freshness_policy + + +def parse_intervals(intervals_elem, frequency, ns): + interval_elems = intervals_elem.findall(".//t:intervals/t:interval", namespaces=ns) + interval = [] + for interval_elem in interval_elems: + interval.extend(interval_elem.attrib.items()) + + # No intervals expected for Day frequency + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Day: + return None + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Week: + interval_values = [(i[1]).title() for i in interval] + return parse_week_intervals(interval_values) + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + interval_values = [(i[1]) for i in interval] + return parse_month_intervals(interval_values) + + +def parse_week_intervals(interval_values): + # Using existing IntervalItem.Day to check valid weekday string + if not all(hasattr(IntervalItem.Day, day) for day in interval_values): + raise ValueError("Invalid week day defined " + str(interval_values)) + return interval_values + + +def parse_month_intervals(interval_values): + error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + + # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] + # First check if the list only have LastDay value. When using LastDay, there shouldn't be + # any other values, hence checking the first element of the list is enough. + # If the value is not "LastDay", we assume intervals is on list of dates format. + # We created this function instead of using existing MonthlyInterval because we allow list of dates interval, + + intervals = [] + if interval_values[0] == "LastDay": + intervals.append(interval_values[0]) + else: + for interval in interval_values: + try: + if 1 <= int(interval) <= 31: + intervals.append(interval) + else: + raise ValueError(error) + except ValueError: + if interval_values[0] != "LastDay": + raise ValueError(error) + return intervals diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 58c33699b..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -147,15 +147,7 @@ def property_is_data_acceleration_config(func): def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) - if len(value) != 4 or not all( - attr in value.keys() - for attr in ( - "acceleration_enabled", - "accelerate_now", - "last_updated_at", - "acceleration_status", - ) - ): + if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 90cff490b..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -31,6 +31,10 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + self._data_acceleration_config = { + "acceleration_enabled": None, + "acceleration_status": None, + } def __str__(self): return "".format( @@ -133,6 +137,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def data_acceleration_config(self): + return self._data_acceleration_config + + @data_acceleration_config.setter + def data_acceleration_config(self, value): + self._data_acceleration_config = value + @property def permissions(self) -> List[PermissionsRule]: if self._permissions is None: @@ -164,6 +176,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) tags_elem = view_xml.find(".//t:tags", namespaces=ns) + data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) view_item._id = view_xml.get("id", None) @@ -186,4 +199,25 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if data_acceleration_config_elem is not None: + data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) + view_item.data_acceleration_config = data_acceleration_config return view_item + + +def parse_data_acceleration_config(data_acceleration_elem): + data_acceleration_config = dict() + + acceleration_enabled = data_acceleration_elem.get("accelerationEnabled", None) + if acceleration_enabled is not None: + acceleration_enabled = string_to_bool(acceleration_enabled) + + acceleration_status = data_acceleration_elem.get("accelerationStatus", None) + + data_acceleration_config["acceleration_enabled"] = acceleration_enabled + data_acceleration_config["acceleration_status"] = acceleration_status + return data_acceleration_config + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 57ddf83f8..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -17,6 +17,7 @@ from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem +from .data_freshness_policy_item import DataFreshnessPolicyItem class WorkbookItem(object): @@ -34,7 +35,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None @@ -49,6 +50,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, "last_updated_at": None, "acceleration_status": None, } + self.data_freshness_policy = None self._permissions = None return None @@ -166,6 +168,10 @@ def views(self) -> List[ViewItem]: # We had views included in a WorkbookItem response return self._views + @views.setter + def views(self, value): + self._views = value + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -175,6 +181,15 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def data_freshness_policy(self): + return self._data_freshness_policy + + @data_freshness_policy.setter + # @property_is_data_freshness_policy + def data_freshness_policy(self, value): + self._data_freshness_policy = value + @property def revisions(self) -> List[RevisionItem]: if self._revisions is None: @@ -221,8 +236,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, _, - _, + views, data_acceleration_config, + data_freshness_policy, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -239,8 +255,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, None, - None, + views, data_acceleration_config, + data_freshness_policy, ) return self @@ -262,6 +279,7 @@ def _set_values( tags, views, data_acceleration_config, + data_freshness_policy, ): if id is not None: self._id = id @@ -290,10 +308,12 @@ def _set_values( if tags: self.tags = tags self._initial_tags = copy.copy(tags) - if views: + if views is not None: self._views = views if data_acceleration_config is not None: self.data_acceleration_config = data_acceleration_config + if data_freshness_policy is not None: + self.data_freshness_policy = data_freshness_policy @classmethod def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: @@ -360,6 +380,11 @@ def _parse_element(workbook_xml, ns): if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) + data_freshness_policy = None + data_freshness_policy_elem = workbook_xml.find(".//t:dataFreshnessPolicy", namespaces=ns) + if data_freshness_policy_elem is not None: + data_freshness_policy = DataFreshnessPolicyItem.from_xml_element(data_freshness_policy_elem, ns) + return ( id, name, @@ -376,6 +401,7 @@ def _parse_element(workbook_xml, ns): tags, views, data_acceleration_config, + data_freshness_policy, ) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 393a028c8..bc535b2d6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -137,7 +137,12 @@ def delete(self, workbook_id: str) -> None: # Update workbook @api(version="2.0") - def update(self, workbook_item: WorkbookItem) -> WorkbookItem: + @parameter_added_in(include_view_acceleration_status="3.22") + def update( + self, + workbook_item: WorkbookItem, + include_view_acceleration_status: bool = False, + ) -> WorkbookItem: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -146,6 +151,9 @@ def update(self, workbook_item: WorkbookItem) -> WorkbookItem: # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) + if include_view_acceleration_status: + url += "?includeViewAccelerationStatus=True" + update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 70d2b30fc..1f6dfbfc6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -57,6 +57,11 @@ def _add_hiddenview_element(views_element, view_name): view_element.attrib["hidden"] = "true" +def _add_view_element(views_element, view_id): + view_element = ET.SubElement(views_element, "view") + view_element.attrib["id"] = view_id + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") if connection_credentials.password is None or connection_credentials.name is None: @@ -944,16 +949,61 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, "owner") owner_element.attrib["id"] = workbook_item.owner_id - if workbook_item.data_acceleration_config["acceleration_enabled"] is not None: + if workbook_item._views is not None: + views_element = ET.SubElement(workbook_element, "views") + for view in workbook_item.views: + _add_view_element(views_element, view.id) + if workbook_item.data_acceleration_config: data_acceleration_config = workbook_item.data_acceleration_config data_acceleration_element = ET.SubElement(workbook_element, "dataAccelerationConfig") - data_acceleration_element.attrib["accelerationEnabled"] = str( - data_acceleration_config["acceleration_enabled"] - ).lower() + if data_acceleration_config["acceleration_enabled"] is not None: + data_acceleration_element.attrib["accelerationEnabled"] = str( + data_acceleration_config["acceleration_enabled"] + ).lower() if data_acceleration_config["accelerate_now"] is not None: data_acceleration_element.attrib["accelerateNow"] = str( data_acceleration_config["accelerate_now"] ).lower() + if workbook_item.data_freshness_policy is not None: + data_freshness_policy_config = workbook_item.data_freshness_policy + data_freshness_policy_element = ET.SubElement(workbook_element, "dataFreshnessPolicy") + data_freshness_policy_element.attrib["option"] = str(data_freshness_policy_config.option) + # Fresh Every Schedule + if data_freshness_policy_config.option == "FreshEvery": + if data_freshness_policy_config.fresh_every_schedule is not None: + fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) + else: + raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") + # Fresh At Schedule + if data_freshness_policy_config.option == "FreshAt": + if data_freshness_policy_config.fresh_at_schedule is not None: + fresh_at_element = ET.SubElement(data_freshness_policy_element, "freshAtSchedule") + frequency = data_freshness_policy_config.fresh_at_schedule.frequency + fresh_at_element.attrib["frequency"] = frequency + fresh_at_element.attrib["time"] = str(data_freshness_policy_config.fresh_at_schedule.time) + fresh_at_element.attrib["timezone"] = str(data_freshness_policy_config.fresh_at_schedule.timezone) + intervals = data_freshness_policy_config.fresh_at_schedule.interval_item + # Fresh At Schedule intervals if Frequency is Week or Month + if frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + if intervals is not None: + # if intervals is not None or frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + intervals_element = ET.SubElement(fresh_at_element, "intervals") + for interval in intervals: + expression = IntervalItem.Occurrence.WeekDay + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + expression = IntervalItem.Occurrence.MonthDay + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = interval + else: + raise ValueError( + f"fresh_at_schedule.interval_item must be populated for " f"Week & Month frequency." + ) + else: + raise ValueError(f"data_freshness_policy_config.fresh_at_schedule must be populated.") return ET.tostring(xml_request) diff --git a/test/assets/workbook_get_by_id_acceleration_status.xml b/test/assets/workbook_get_by_id_acceleration_status.xml new file mode 100644 index 000000000..0d1f9b93d --- /dev/null +++ b/test/assets/workbook_get_by_id_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_update_acceleration_status.xml b/test/assets/workbook_update_acceleration_status.xml new file mode 100644 index 000000000..7c3366fee --- /dev/null +++ b/test/assets/workbook_update_acceleration_status.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy.xml b/test/assets/workbook_update_data_freshness_policy.xml new file mode 100644 index 000000000..a69a097ba --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy2.xml b/test/assets/workbook_update_data_freshness_policy2.xml new file mode 100644 index 000000000..384f79ec0 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy2.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy3.xml b/test/assets/workbook_update_data_freshness_policy3.xml new file mode 100644 index 000000000..195013517 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy3.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy4.xml b/test/assets/workbook_update_data_freshness_policy4.xml new file mode 100644 index 000000000..8208d986a --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy4.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy5.xml b/test/assets/workbook_update_data_freshness_policy5.xml new file mode 100644 index 000000000..b6e0358b6 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy5.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy6.xml b/test/assets/workbook_update_data_freshness_policy6.xml new file mode 100644 index 000000000..c8be8f6c1 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy6.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_views_acceleration_status.xml b/test/assets/workbook_update_views_acceleration_status.xml new file mode 100644 index 000000000..f2055fb79 --- /dev/null +++ b/test/assets/workbook_update_views_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_data_freshness_policy.py b/test/test_data_freshness_policy.py new file mode 100644 index 000000000..9591a6380 --- /dev/null +++ b/test/test_data_freshness_policy.py @@ -0,0 +1,189 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +UPDATE_DFP_ALWAYS_LIVE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy.xml") +UPDATE_DFP_SITE_DEFAULT_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy2.xml") +UPDATE_DFP_FRESH_EVERY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy3.xml") +UPDATE_DFP_FRESH_AT_DAILY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy4.xml") +UPDATE_DFP_FRESH_AT_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy5.xml") +UPDATE_DFP_FRESH_AT_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy6.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_update_DFP_always_live(self) -> None: + with open(UPDATE_DFP_ALWAYS_LIVE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("AlwaysLive", single_workbook.data_freshness_policy.option) + + def test_update_DFP_site_default(self) -> None: + with open(UPDATE_DFP_SITE_DEFAULT_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("SiteDefault", single_workbook.data_freshness_policy.option) + + def test_update_DFP_fresh_every(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshEvery", single_workbook.data_freshness_policy.option) + self.assertEqual("Hours", single_workbook.data_freshness_policy.fresh_every_schedule.frequency) + self.assertEqual(10, single_workbook.data_freshness_policy.fresh_every_schedule.value) + + def test_update_DFP_fresh_every_missing_attributes(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_day(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Day", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("22:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Asia/Singapore", single_workbook.data_freshness_policy.fresh_at_schedule.timezone) + + def test_update_DFP_fresh_at_week(self) -> None: + with open(UPDATE_DFP_FRESH_AT_WEEKLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "10:00:00", + "America/Los_Angeles", + ["Monday", "Wednesday"], + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Week", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("10:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Wednesday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + self.assertEqual("Monday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1]) + + def test_update_DFP_fresh_at_month(self) -> None: + with open(UPDATE_DFP_FRESH_AT_MONTHLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Month", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("00:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("LastDay", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + + def test_update_DFP_fresh_at_missing_params(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_missing_interval(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py new file mode 100644 index 000000000..6f94f0c10 --- /dev/null +++ b/test/test_view_acceleration.py @@ -0,0 +1,119 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_BY_ID_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_acceleration_status.xml") +POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") +UPDATE_VIEWS_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_views_acceleration_status.xml") +UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_acceleration_status.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_get_by_id(self) -> None: + with open(GET_BY_ID_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id) + self.assertEqual("SafariSample", single_workbook.name) + self.assertEqual("SafariSample", single_workbook.content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) + self.assertEqual(False, single_workbook.show_tabs) + self.assertEqual(26, single_workbook.size) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) + self.assertEqual("description for SafariSample", single_workbook.description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) + self.assertEqual("default", single_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) + self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Enabled", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(False, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Suspended", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_workbook_acceleration(self) -> None: + with open(UPDATE_WORKBOOK_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(True, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_views_acceleration(self) -> None: + with open(POPULATE_VIEWS_XML, "rb") as f: + views_xml = f.read().decode("utf-8") + with open(UPDATE_VIEWS_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=views_xml) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": False, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + self.server.workbooks.populate_views(single_workbook) + single_workbook.views = [single_workbook.views[1], single_workbook.views[2]] + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + views_list = single_workbook.views + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) + self.assertEqual("GDP per capita", views_list[0].name) + self.assertEqual(False, views_list[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Disabled", views_list[0].data_acceleration_config["acceleration_status"]) + + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) + self.assertEqual("Country ranks", views_list[1].name) + self.assertEqual(True, views_list[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[1].data_acceleration_config["acceleration_status"]) + + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) + self.assertEqual("Interest rates", views_list[2].name) + self.assertEqual(True, views_list[2].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[2].data_acceleration_config["acceleration_status"]) From 114214beb947db6bf74926337bb14fbd8e7d1c45 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 26 Apr 2024 18:27:19 -0700 Subject: [PATCH 378/567] Improve robustness of Pager results In some cases, Tableau Server might have a different between the advertised total number of object and the actual number returned via the Pager. This change adds one more check to prevent errors from happening in these situations. Fixes #1304 --- tableauserverclient/server/pager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index b65d75ae5..3220f5372 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -47,7 +47,11 @@ def __iter__(self): # Get the rest on demand as a generator while self._count < last_pagination_item.total_available: - if len(current_item_list) == 0: + if ( + len(current_item_list) == 0 + and (last_pagination_item.page_number * last_pagination_item.page_size) + < last_pagination_item.total_available + ): current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) try: From bdce9822ffbac122b5a7072497fe1e841084c012 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Tue, 7 May 2024 21:41:32 -0700 Subject: [PATCH 379/567] Add Cloud Flow Task endpoint --- tableauserverclient/models/task_item.py | 1 + .../server/endpoint/flow_task_endpoint.py | 29 +++++++++ tableauserverclient/server/request_factory.py | 37 +++++++++++ tableauserverclient/server/server.py | 2 + test/test_flowtask.py | 61 +++++++++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 tableauserverclient/server/endpoint/flow_task_endpoint.py create mode 100644 test/test_flowtask.py diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 0ffc3bfab..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -18,6 +18,7 @@ class Type: _TASK_TYPE_MAPPING = { "RefreshExtractTask": Type.ExtractRefresh, "MaterializeViewsTask": Type.DataAcceleration, + "RunFlowTask": Type.RunFlow, } def __init__( diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py new file mode 100644 index 000000000..1e53b22f1 --- /dev/null +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -0,0 +1,29 @@ +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory + +from tableauserverclient.helpers.logging import logger + +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class FlowTasks(Endpoint): + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.22") + def create(self, flow_item: TaskItem) -> TaskItem: + if not flow_item: + error = "No flow provided" + raise ValueError(error) + logger.info("Creating an flow task %s", flow_item) + url = self.baseurl + create_req = RequestFactory.Task.create_flow_task_req(flow_item) + server_response = self.post_request(url, create_req) + return server_response.content \ No newline at end of file diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 1f6dfbfc6..904df1215 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1113,6 +1113,43 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) +class FlowTaskRequest(object): + @_tsrequest_wrapped + def run_req(self, xml_request, task_item): + # Send an empty tsRequest + pass + + @_tsrequest_wrapped + def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: + flow_element = ET.SubElement(xml_request, "runFlow") + + # Main attributes + flow_element.attrib["type"] = flow_item.task_type + + if flow_item.target is not None: + target_element = ET.SubElement(flow_element, flow_item.target.type) + target_element.attrib["id"] = flow_item.target.id + + if flow_item.schedule_item is None: + return ET.tostring(xml_request) + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = flow_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") + for interval in interval_item._interval_type_pairs(): # type: ignore + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + return ET.tostring(xml_request) class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ee23789b1..3a6831458 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -25,6 +25,7 @@ Databases, Tables, Flows, + FlowTasks, Webhooks, DataAccelerationReport, Favorites, @@ -82,6 +83,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.datasources = Datasources(self) self.favorites = Favorites(self) self.flows = Flows(self) + self.flow_tasks = FlowTasks(self) self.projects = Projects(self) self.schedules = Schedules(self) self.server_info = ServerInfo(self) diff --git a/test/test_flowtask.py b/test/test_flowtask.py new file mode 100644 index 000000000..aaa4b0932 --- /dev/null +++ b/test/test_flowtask.py @@ -0,0 +1,61 @@ +import os +import unittest +from datetime import time +from pathlib import Path + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.task_item import TaskItem + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml") +GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") +GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") +GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") +GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") +GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" +GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" + +GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") + + + +class TaskTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake Signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + # default task type is extractRefreshes TODO change this + # self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") + self.baseurl = self.server.flow_tasks.baseurl + + def test_create_flow_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + target_item = TSC.Target("flow_id", "flow") + + task = TaskItem(schedule_item=monthly_schedule, target=target_item) + # task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post("{}".format(self.baseurl), text=response_xml) + create_response_content = self.server.flow_tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("flow_id" in create_response_content) + #self.assertTrue("FullRefresh" in create_response_content) From 67812858dd4ce43154d8ce9e22fbdc069875ffce Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 11:44:54 -0700 Subject: [PATCH 380/567] cleanup --- tableauserverclient/server/endpoint/__init__.py | 1 + tableauserverclient/server/endpoint/flow_task_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 1 + test/test_flowtask.py | 4 ---- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index c018d8334..b2f291369 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -10,6 +10,7 @@ from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns from .flows_endpoint import Flows +from .flow_task_endpoint import FlowTasks from .groups_endpoint import Groups from .jobs_endpoint import Jobs from .metadata_endpoint import Metadata diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 1e53b22f1..18a9c2550 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -24,6 +24,6 @@ def create(self, flow_item: TaskItem) -> TaskItem: raise ValueError(error) logger.info("Creating an flow task %s", flow_item) url = self.baseurl - create_req = RequestFactory.Task.create_flow_task_req(flow_item) + create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item) server_response = self.post_request(url, create_req) return server_response.content \ No newline at end of file diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 904df1215..825451187 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1290,6 +1290,7 @@ class RequestFactory(object): Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() + FlowTask = FlowTaskRequest() Group = GroupRequest() Metric = MetricRequest() Permission = PermissionRequest() diff --git a/test/test_flowtask.py b/test/test_flowtask.py index aaa4b0932..8588d5701 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -32,8 +32,6 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - # default task type is extractRefreshes TODO change this - # self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") self.baseurl = self.server.flow_tasks.baseurl def test_create_flow_task(self): @@ -48,7 +46,6 @@ def test_create_flow_task(self): target_item = TSC.Target("flow_id", "flow") task = TaskItem(schedule_item=monthly_schedule, target=target_item) - # task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") @@ -58,4 +55,3 @@ def test_create_flow_task(self): self.assertTrue("task_id" in create_response_content) self.assertTrue("flow_id" in create_response_content) - #self.assertTrue("FullRefresh" in create_response_content) From 06b76d6dbce43cecb1b872d265c764b614d4fad7 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 14:12:53 -0700 Subject: [PATCH 381/567] black format --- tableauserverclient/server/endpoint/flow_task_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 8 +++++--- test/test_flowtask.py | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 18a9c2550..eea3f9710 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -26,4 +26,4 @@ def create(self, flow_item: TaskItem) -> TaskItem: url = self.baseurl create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item) server_response = self.post_request(url, create_req) - return server_response.content \ No newline at end of file + return server_response.content diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 825451187..cca4b82a6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -972,9 +972,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib[ - "frequency" - ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["frequency"] = ( + data_freshness_policy_config.fresh_every_schedule.frequency + ) fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1113,6 +1113,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) + class FlowTaskRequest(object): @_tsrequest_wrapped def run_req(self, xml_request, task_item): @@ -1151,6 +1152,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 8588d5701..61a09b429 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -22,7 +22,6 @@ GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") - class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) From 4735bd31185c6dec8b1fdccce86ee8aa32f129dd Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 14:29:26 -0700 Subject: [PATCH 382/567] add xml --- test/assets/tasks_create_flow_task.xml | 14 ++++++++++++++ test/test_flowtask.py | 9 --------- 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 test/assets/tasks_create_flow_task.xml diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml new file mode 100644 index 000000000..44826a94a --- /dev/null +++ b/test/assets/tasks_create_flow_task.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 61a09b429..1f7d82c30 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -10,15 +10,6 @@ from tableauserverclient.models.task_item import TaskItem TEST_ASSET_DIR = Path(__file__).parent / "assets" - -GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml") -GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") -GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") -GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") -GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") -GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" -GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" - GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") From d6fd8291378d2393a02a8dc96cd46853d2455515 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:17:40 -0700 Subject: [PATCH 383/567] edit test initialization --- test/assets/tasks_create_flow_task.xml | 38 ++++++++++++++++++-------- test/test_flowtask.py | 8 +++--- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml index 44826a94a..b5a6aa6f4 100644 --- a/test/assets/tasks_create_flow_task.xml +++ b/test/assets/tasks_create_flow_task.xml @@ -1,14 +1,28 @@ - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 1f7d82c30..ed2627147 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -27,10 +27,10 @@ def setUp(self): def test_create_flow_task(self): monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) monthly_schedule = TSC.ScheduleItem( - None, - None, - None, - None, + "Monthly Schedule", + 50, + TSC.ScheduleItem.Type.Flow, + TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval, ) target_item = TSC.Target("flow_id", "flow") From 7f11a6d4ff7d4da1d526784d30ef30182f9592aa Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:31:14 -0700 Subject: [PATCH 384/567] fix task initialization --- test/test_flowtask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index ed2627147..dd2d07eef 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -35,7 +35,7 @@ def test_create_flow_task(self): ) target_item = TSC.Target("flow_id", "flow") - task = TaskItem(schedule_item=monthly_schedule, target=target_item) + task = TaskItem(None, "RunFlow", None, schedule_item=monthly_schedule, target=target_item) with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") From c746957b3293f1fedc46af86f07432d86bc803b5 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:45:12 -0700 Subject: [PATCH 385/567] third times the charm --- test/assets/tasks_create_flow_task.xml | 12 ++++++------ test/test_flowtask.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml index b5a6aa6f4..11c9a4ff0 100644 --- a/test/assets/tasks_create_flow_task.xml +++ b/test/assets/tasks_create_flow_task.xml @@ -1,11 +1,11 @@ - - - - + - diff --git a/test/test_flowtask.py b/test/test_flowtask.py index dd2d07eef..034066e64 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -43,5 +43,5 @@ def test_create_flow_task(self): m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") - self.assertTrue("task_id" in create_response_content) + self.assertTrue("schedule_id" in create_response_content) self.assertTrue("flow_id" in create_response_content) From 0e5ce785d601a3c013c97a305188d281a867c866 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:51:58 -0700 Subject: [PATCH 386/567] cleanup --- tableauserverclient/server/request_factory.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index cca4b82a6..61507ea2e 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1115,11 +1115,6 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") class FlowTaskRequest(object): - @_tsrequest_wrapped - def run_req(self, xml_request, task_item): - # Send an empty tsRequest - pass - @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") From bcb02ac5e294246e07859ddc1281bba11b58ee09 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Thu, 9 May 2024 17:33:27 -0700 Subject: [PATCH 387/567] fix formatting --- tableauserverclient/server/request_factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 61507ea2e..c204e7217 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -972,9 +972,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib["frequency"] = ( - data_freshness_policy_config.fresh_every_schedule.frequency - ) + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") From 435f1aed2e25542b894070440558289f8527a53c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 9 May 2024 21:06:35 -0500 Subject: [PATCH 388/567] feat: pass parameters in request options --- tableauserverclient/server/request_options.py | 16 +++++++++++++-- test/test_request_option.py | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 8304b8f68..5cc06bf9d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,7 @@ import sys +from typing_extensions import Self + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -154,17 +156,27 @@ class _FilterOptionsBase(RequestOptionsBase): def __init__(self): self.view_filters = [] + self.view_parameters = [] def get_query_params(self): raise NotImplementedError() - def vf(self, name, value): + def vf(self, name: str, value: str) -> Self: + """Apply a filter to the view for a filter that is a normal column + within the view.""" self.view_filters.append((name, value)) return self - def _append_view_filters(self, params): + def parameter(self, name: str, value: str) -> Self: + """Apply a filter based on a parameter within the workbook.""" + self.view_parameters.append((name, value)) + return self + + def _append_view_filters(self, params) -> None: for name, value in self.view_filters: params["vf_" + name] = value + for name, value in self.view_parameters: + params[name] = value class CSVRequestOptions(_FilterOptionsBase): diff --git a/test/test_request_option.py b/test/test_request_option.py index 32526d1e6..40dd3345a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -2,6 +2,7 @@ from pathlib import Path import re import unittest +from urllib.parse import parse_qs import requests_mock @@ -311,3 +312,22 @@ def test_slicing_queryset_multi_page(self) -> None: def test_queryset_filter_args_error(self) -> None: with self.assertRaises(RuntimeError): workbooks = self.server.workbooks.filter("argument") + + def test_filtering_parameters(self) -> None: + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.parameter("name1@", "value1") + opts.parameter("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = self.server.workbooks.get_request(url, request_object=opts) + query_params = parse_qs(resp.request.query) + self.assertIn("name1@", query_params) + self.assertIn("value1", query_params["name1@"]) + self.assertIn("name2$", query_params) + self.assertIn("value2", query_params["name2$"]) + self.assertIn("type", query_params) + self.assertIn("tabloid", query_params["type"]) From 397e275804a7321a7c2b0e45ee8e91c2f6ca11c8 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 9 May 2024 21:09:41 -0500 Subject: [PATCH 389/567] chore: pin typing_extensions version --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9c35a42e7..fceb37237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 'urllib3==2.0.7', # latest as at 7/31/23 + 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" classifiers = [ From 4029583561f4bda1ace8167e4feaba82a071ced5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 6 Apr 2024 20:36:24 -0500 Subject: [PATCH 390/567] feat: enable combining PermissionsRules --- .gitignore | 1 + .../models/permissions_item.py | 26 ++++++++++ tableauserverclient/models/reference_item.py | 3 ++ test/test_permissionsrule.py | 49 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 test/test_permissionsrule.py diff --git a/.gitignore b/.gitignore index 92778cd81..b3b3ff80f 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ docs/_site/ docs/.jekyll-metadata docs/Gemfile.lock samples/credentials +.venv/ diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index d2b2227db..71ffb7013 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -53,6 +53,32 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) + def __and__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot AND two permissions rules with different grantees") + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Allow, Permission.Mode.Allow): + new_capabilities[capability] = Permission.Mode.Allow + elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + + def __or__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot AND two permissions rules with different grantees") + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Allow + elif (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Deny, Permission.Mode.Deny): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 6fc6b0c22..c46f96867 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,6 +8,9 @@ def __str__(self): __repr__ = __str__ + def __eq__(self, other): + return (self.id == other.id) and (self.tag_name == other.tag_name) + @property def id(self): return self._id diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py new file mode 100644 index 000000000..34965d610 --- /dev/null +++ b/test/test_permissionsrule.py @@ -0,0 +1,49 @@ +import unittest + +import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference + +class TestPermissionsRules(unittest.TestCase): + def test_and(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + rule2 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + + composite = rule1 & rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Deny) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + + def test_or(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + rule2 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + + composite = rule1 | rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + From e8b01dddec2533a1853dd277ddfa7f09a263423c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 29 May 2024 22:10:34 -0500 Subject: [PATCH 391/567] style: black --- .../models/permissions_item.py | 10 +++- test/test_permissionsrule.py | 59 +++++++++++-------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ffb7013..14a97169c 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -59,7 +59,10 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: - if (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Allow, Permission.Mode.Allow): + if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Allow, + Permission.Mode.Allow, + ): new_capabilities[capability] = Permission.Mode.Allow elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)): new_capabilities[capability] = Permission.Mode.Deny @@ -74,7 +77,10 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): new_capabilities[capability] = Permission.Mode.Allow - elif (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Deny, Permission.Mode.Deny): + elif (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Deny, + Permission.Mode.Deny, + ): new_capabilities[capability] = Permission.Mode.Deny return PermissionsRule(self.grantee, new_capabilities) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index 34965d610..7f18055ab 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -3,20 +3,27 @@ import tableauserverclient as TSC from tableauserverclient.models.reference_item import ResourceReference + class TestPermissionsRules(unittest.TestCase): def test_and(self): grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) - rule2 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) composite = rule1 & rule2 @@ -25,20 +32,25 @@ def test_and(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) - def test_or(self): grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) - rule2 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) composite = rule1 | rule2 @@ -46,4 +58,3 @@ def test_or(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) - From 6dcabb29371866198062d8ea1d681d25d382f1f4 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:21:22 -0500 Subject: [PATCH 392/567] fix: typo in exception --- tableauserverclient/models/permissions_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 14a97169c..61afa16ee 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -71,7 +71,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: - raise ValueError("Cannot AND two permissions rules with different grantees") + raise ValueError("Cannot OR two permissions rules with different grantees") capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: From 691ba7f6b16b9c935ad4c8e783b674c8d0b307c1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:38:08 -0500 Subject: [PATCH 393/567] feat: add eq comparison for PermissionsRule --- .../models/permissions_item.py | 11 +++++ test/test_permissionsrule.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 61afa16ee..949f861ca 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -53,9 +53,16 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) + def __eq__(self, other: "PermissionsRule") -> bool: + return self.grantee == other.grantee and self.capabilities == other.capabilities + def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: raise ValueError("Cannot AND two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: @@ -72,6 +79,10 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: raise ValueError("Cannot OR two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index 7f18055ab..c10bc1e92 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -58,3 +58,49 @@ def test_or(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + def test_eq_false(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + self.assertNotEqual(rule1, rule2) + + def test_eq_true(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + self.assertEqual(rule1, rule2) + + From 07e1fe22911f36618564cca23dbf49b45ef768af Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:42:43 -0500 Subject: [PATCH 394/567] style: black --- test/test_permissionsrule.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index c10bc1e92..d7bceb258 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -102,5 +102,3 @@ def test_eq_true(self): }, ) self.assertEqual(rule1, rule2) - - From 73b125a5b47478563cd8255799f0adb6818be2d9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:47:15 -0500 Subject: [PATCH 395/567] fix: generalize eq methods --- tableauserverclient/models/permissions_item.py | 6 ++++-- tableauserverclient/models/reference_item.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 949f861ca..fecdb9723 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -45,7 +45,7 @@ def __repr__(self): return "" -class PermissionsRule(object): +class PermissionsRule: def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -53,7 +53,9 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) - def __eq__(self, other: "PermissionsRule") -> bool: + def __eq__(self, other: object) -> bool: + if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): + return False return self.grantee == other.grantee and self.capabilities == other.capabilities def __and__(self, other: "PermissionsRule") -> "PermissionsRule": diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index c46f96867..99c990287 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,7 +8,9 @@ def __str__(self): __repr__ = __str__ - def __eq__(self, other): + def __eq__(self, other: object): + if not hasattr(other, 'id') or not hasattr(other, 'tag_name'): + return False return (self.id == other.id) and (self.tag_name == other.tag_name) @property From 2c0e2bdc49ed28079cd19aab820fd683e454beb7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:49:07 -0500 Subject: [PATCH 396/567] style: black --- tableauserverclient/models/reference_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 99c990287..b69e43db7 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -9,7 +9,7 @@ def __str__(self): __repr__ = __str__ def __eq__(self, other: object): - if not hasattr(other, 'id') or not hasattr(other, 'tag_name'): + if not hasattr(other, "id") or not hasattr(other, "tag_name"): return False return (self.id == other.id) and (self.tag_name == other.tag_name) From cad17111e06f84cd2b7ecbb000e911d28093602b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:52:44 -0500 Subject: [PATCH 397/567] fix: add missing type hint --- tableauserverclient/models/reference_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index b69e43db7..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,7 +8,7 @@ def __str__(self): __repr__ = __str__ - def __eq__(self, other: object): + def __eq__(self, other: object) -> bool: if not hasattr(other, "id") or not hasattr(other, "tag_name"): return False return (self.id == other.id) and (self.tag_name == other.tag_name) From 4018a0ffc01bfa7c5b0024c6f112ced158d420b9 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 3 Jun 2024 12:52:39 -0700 Subject: [PATCH 398/567] v0.31 (#1378) * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * Changes to alter cgi dependency to email.Messages * feat: allow viz height and width parameters * fix: use python3.8 syntax * fix: python3.8 syntax * docs: comment PDF viz dimensions XOR * Add support for System schedule type I'm not fully clear on where these might come from, but this change should let TSC work in such cases. Fixes #1349 * Add failing test retrieving a task with 24 hour (aka daily) interval * Add 24 (hours) as a valid interval which can be returned from the server * Add Python 3.12 to test matrix * Tweak test action to stop double-running everything * feat: add description support on wb publish * Add Data Acceleration and Data Freshness Policy support (#1343) * Add data acceleration & data freshness policy functions * Add unit tests and raise errors on missing params * fix types & spell checks * addressed some feedback * addressed feedback * cleanup code * Revert "Merge branch 'add_data_acceleration_and_data_freshness_policy_support' of https://github.com/tableau/server-client-python into add_data_acceleration_and_data_freshness_policy_support" This reverts commit 5b30e57d959ae80b8279d7eeb2e4f374fc111664, reversing changes made to 5789e32bd57f4459209da05003f1ccf4e93e01a1. * fix formatting * Address feedback * mypy & formatting changes * Improve robustness of Pager results In some cases, Tableau Server might have a different between the advertised total number of object and the actual number returned via the Pager. This change adds one more check to prevent errors from happening in these situations. Fixes #1304 * Add Cloud Flow Task endpoint * cleanup * black format * add xml * edit test initialization * fix task initialization * third times the charm * cleanup * fix formatting * feat: pass parameters in request options * chore: pin typing_extensions version --------- Co-authored-by: markm Co-authored-by: Mark Moreno <45011486+markm-io@users.noreply.github.com> Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni Co-authored-by: Brian Cantoni Co-authored-by: ltiffanydev <148500608+ltiffanydev@users.noreply.github.com> Co-authored-by: liu.r --- .github/workflows/run-tests.yml | 9 +- pyproject.toml | 1 + samples/update_workbook_data_acceleration.py | 109 +++++++++ .../update_workbook_data_freshness_policy.py | 218 ++++++++++++++++++ tableauserverclient/__init__.py | 1 + tableauserverclient/models/__init__.py | 1 + .../models/data_freshness_policy_item.py | 210 +++++++++++++++++ tableauserverclient/models/interval_item.py | 2 +- .../models/property_decorators.py | 17 +- tableauserverclient/models/schedule_item.py | 1 + tableauserverclient/models/task_item.py | 1 + tableauserverclient/models/view_item.py | 34 +++ tableauserverclient/models/workbook_item.py | 38 ++- .../server/endpoint/__init__.py | 1 + .../server/endpoint/datasources_endpoint.py | 8 +- .../server/endpoint/flow_task_endpoint.py | 29 +++ .../server/endpoint/flows_endpoint.py | 8 +- .../server/endpoint/workbooks_endpoint.py | 18 +- tableauserverclient/server/pager.py | 6 +- tableauserverclient/server/request_factory.py | 96 +++++++- tableauserverclient/server/request_options.py | 50 +++- tableauserverclient/server/server.py | 2 + test/assets/schedule_get.xml | 1 + test/assets/tasks_create_flow_task.xml | 28 +++ test/assets/tasks_with_interval.xml | 20 ++ ...workbook_get_by_id_acceleration_status.xml | 19 ++ test/assets/workbook_publish.xml | 4 +- .../workbook_update_acceleration_status.xml | 16 ++ .../workbook_update_data_freshness_policy.xml | 9 + ...workbook_update_data_freshness_policy2.xml | 9 + ...workbook_update_data_freshness_policy3.xml | 11 + ...workbook_update_data_freshness_policy4.xml | 12 + ...workbook_update_data_freshness_policy5.xml | 16 ++ ...workbook_update_data_freshness_policy6.xml | 15 ++ ...kbook_update_views_acceleration_status.xml | 19 ++ test/test_data_freshness_policy.py | 189 +++++++++++++++ test/test_flowtask.py | 47 ++++ test/test_request_option.py | 20 ++ test/test_schedule.py | 10 + test/test_task.py | 10 + test/test_view.py | 29 +++ test/test_view_acceleration.py | 119 ++++++++++ test/test_workbook.py | 3 + 43 files changed, 1428 insertions(+), 38 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 samples/update_workbook_data_freshness_policy.py create mode 100644 tableauserverclient/models/data_freshness_policy_item.py create mode 100644 tableauserverclient/server/endpoint/flow_task_endpoint.py create mode 100644 test/assets/tasks_create_flow_task.xml create mode 100644 test/assets/tasks_with_interval.xml create mode 100644 test/assets/workbook_get_by_id_acceleration_status.xml create mode 100644 test/assets/workbook_update_acceleration_status.xml create mode 100644 test/assets/workbook_update_data_freshness_policy.xml create mode 100644 test/assets/workbook_update_data_freshness_policy2.xml create mode 100644 test/assets/workbook_update_data_freshness_policy3.xml create mode 100644 test/assets/workbook_update_data_freshness_policy4.xml create mode 100644 test/assets/workbook_update_data_freshness_policy5.xml create mode 100644 test/assets/workbook_update_data_freshness_policy6.xml create mode 100644 test/assets/workbook_update_views_acceleration_status.xml create mode 100644 test/test_data_freshness_policy.py create mode 100644 test/test_flowtask.py create mode 100644 test/test_view_acceleration.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6b1629bfd..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,11 @@ name: Python tests -on: [push, pull_request] +on: + pull_request: {} + push: + branches: + - development + - master jobs: build: @@ -8,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 9c35a42e7..fceb37237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 'urllib3==2.0.7', # latest as at 7/31/23 + 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" classifiers = [ diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py new file mode 100644 index 000000000..75f12262f --- /dev/null +++ b/samples/update_workbook_data_acceleration.py @@ -0,0 +1,109 @@ +#### +# This script demonstrates how to update workbook data acceleration using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook to try data acceleration. + # Note that data acceleration has a couple of requirements, please check the Tableau help page + # to verify your workbook/view is eligible for data acceleration. + + # Assuming 1st workbook is eligible for sample purposes + sample_workbook = all_workbooks[2] + + # Enable acceleration for all the views in the workbook + enable_config = dict() + enable_config["acceleration_enabled"] = True + enable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = enable_config + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + # Since we did not set any specific view, we will enable all views in the workbook + print("Enable acceleration for all the views in the workbook " + updated.name + ".") + + # Disable acceleration on one of the view in the workbook + # You have to populate_views first, then set the views of the workbook + # to the ones you want to update. + server.workbooks.populate_views(sample_workbook) + view_to_disable = sample_workbook.views[0] + sample_workbook.views = [view_to_disable] + + disable_config = dict() + disable_config["acceleration_enabled"] = False + disable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = disable_config + # To get the acceleration status on the response, set includeViewAccelerationStatus=true + # Note that you have to populate_views first to get the acceleration status, since + # acceleration status is per view basis (not per workbook) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) + view1 = updated.views[0] + print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") + + # Get acceleration status of the views in workbook using workbooks.get_by_id + # This won't need to do populate_views beforehand + my_workbook = server.workbooks.get_by_id(sample_workbook.id) + view1 = my_workbook.views[0] + view2 = my_workbook.views[1] + print( + "Fetching acceleration status for views in the workbook " + + updated.name + + ".\n" + + 'View "' + + view1.name + + '" has acceleration_status = ' + + view1.data_acceleration_config["acceleration_status"] + + ".\n" + + 'View "' + + view2.name + + '" has acceleration_status = ' + + view2.data_acceleration_config["acceleration_status"] + + "." + ) + + +if __name__ == "__main__": + main() diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py new file mode 100644 index 000000000..9e4d63dc1 --- /dev/null +++ b/samples/update_workbook_data_freshness_policy.py @@ -0,0 +1,218 @@ +#### +# This script demonstrates how to update workbook data freshness policy using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server") + parser.add_argument( + "--token-value", "-v", help="value of the personal access token " "used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook that has live datasource connection. + # Assuming 1st workbook met the criteria for sample purposes + # Data Freshness Policy is not available on extract & file-based datasource. + sample_workbook = all_workbooks[2] + + # Get more info from the workbook selected + # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError + # it could mean the workbook selected does not have live connection, which means it doesn't have + # data freshness policy. Change to another workbook with live datasource connection. + sample_workbook_extended = server.workbooks.get_by_id(sample_workbook.id) + try: + print( + "Workbook " + + sample_workbook.name + + " has data freshness policy option set to: " + + sample_workbook_extended.data_freshness_policy.option + ) + except AttributeError as e: + print( + "Workbook does not have data freshness policy, possibly due to the workbook selected " + "does not have live connection. Change to another workbook using live datasource connection." + ) + + # Update Workbook Data Freshness Policy to "AlwaysLive" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "SiteDefault" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "FreshEvery" schedule. + # Set the schedule to be fresh every 10 hours + # Once the data_freshness_policy is already populated (e.g. due to previous calls), + # it is possible to directly change the option & other parameters directly like below + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshEvery + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + sample_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_every_schedule.value) + + " " + + updated.data_freshness_policy.fresh_every_schedule.frequency + ) + + # Update Workbook Data Freshness Policy to "FreshAt" schedule. + # Set the schedule to be fresh at 10AM every day + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshAt + fresh_at_ten_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "10:00:00", "America/Los_Angeles" + ) + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_ten_daily + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_at_schedule.time) + + " every " + + updated.data_freshness_policy.fresh_at_schedule.frequency + ) + + # Set the schedule to be fresh at 6PM every week on Wednesday and Sunday + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_6pm_wed_sun = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "18:00:00", + "America/Los_Angeles", + [IntervalItem.Day.Wednesday, "Sunday"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_6pm_wed_sun + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + + "," + + new_fresh_at_schedule.interval_item[1] + ) + + # Set the schedule to be fresh at 12AM every last day of the month + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_last_day_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_last_day_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + ) + + # Set the schedule to be fresh at 8PM every 1st,13th,20th day of the month + fresh_at_dates_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, + "00:00:00", + "America/Los_Angeles", + ["1", "13", "20"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_dates_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + str(new_fresh_at_schedule.interval_item) + ) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5c3c1922..f093f521b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -10,6 +10,7 @@ DailyInterval, DataAlertItem, DatabaseItem, + DataFreshnessPolicyItem, DatasourceItem, FavoriteItem, FlowItem, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 03d692583..e7a853d9a 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem from .database_item import DatabaseItem +from .data_freshness_policy_item import DataFreshnessPolicyItem from .datasource_item import DatasourceItem from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py new file mode 100644 index 000000000..f567c501c --- /dev/null +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -0,0 +1,210 @@ +import xml.etree.ElementTree as ET + +from typing import Optional, Union, List +from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable +from .interval_item import IntervalItem + + +class DataFreshnessPolicyItem: + class Option: + AlwaysLive = "AlwaysLive" + SiteDefault = "SiteDefault" + FreshEvery = "FreshEvery" + FreshAt = "FreshAt" + + class FreshEvery: + class Frequency: + Minutes = "Minutes" + Hours = "Hours" + Days = "Days" + Weeks = "Weeks" + + def __init__(self, frequency: str, value: int): + self.frequency: str = frequency + self.value: int = value + + def __repr__(self): + return "".format(**vars(self)) + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_every_schedule_elem: ET.Element): + frequency = fresh_every_schedule_elem.get("frequency", None) + value_str = fresh_every_schedule_elem.get("value", None) + if (frequency is None) or (value_str is None): + return None + value = int(value_str) + return DataFreshnessPolicyItem.FreshEvery(frequency, value) + + class FreshAt: + class Frequency: + Day = "Day" + Week = "Week" + Month = "Month" + + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + self.frequency = frequency + self.time = time + self.timezone = timezone + self.interval_item: Optional[List[str]] = interval_item + + def __repr__(self): + return ( + " timezone={_timezone} " "interval_item={_interval_time}" + ).format(**vars(self)) + + @property + def interval_item(self) -> Optional[List[str]]: + return self._interval_item + + @interval_item.setter + def interval_item(self, value: List[str]): + self._interval_item = value + + @property + def time(self): + return self._time + + @time.setter + @property_not_nullable + def time(self, value): + self._time = value + + @property + def timezone(self) -> str: + return self._timezone + + @timezone.setter + def timezone(self, value: str): + self._timezone = value + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_at_schedule_elem: ET.Element, ns): + frequency = fresh_at_schedule_elem.get("frequency", None) + time = fresh_at_schedule_elem.get("time", None) + if (frequency is None) or (time is None): + return None + timezone = fresh_at_schedule_elem.get("timezone", None) + interval = parse_intervals(fresh_at_schedule_elem, frequency, ns) + return DataFreshnessPolicyItem.FreshAt(frequency, time, timezone, interval) + + def __init__(self, option: str): + self.option = option + self.fresh_every_schedule: Optional[DataFreshnessPolicyItem.FreshEvery] = None + self.fresh_at_schedule: Optional[DataFreshnessPolicyItem.FreshAt] = None + + def __repr__(self): + return "".format(**vars(self)) + + @property + def option(self) -> str: + return self._option + + @option.setter + @property_is_enum(Option) + def option(self, value: str): + self._option = value + + @property + def fresh_every_schedule(self) -> Optional[FreshEvery]: + return self._fresh_every_schedule + + @fresh_every_schedule.setter + def fresh_every_schedule(self, value: FreshEvery): + self._fresh_every_schedule = value + + @property + def fresh_at_schedule(self) -> Optional[FreshAt]: + return self._fresh_at_schedule + + @fresh_at_schedule.setter + def fresh_at_schedule(self, value: FreshAt): + self._fresh_at_schedule = value + + @classmethod + def from_xml_element(cls, data_freshness_policy_elem, ns): + option = data_freshness_policy_elem.get("option", None) + if option is None: + return None + data_freshness_policy = DataFreshnessPolicyItem(option) + + fresh_at_schedule = None + fresh_every_schedule = None + if option == "FreshAt": + fresh_at_schedule_elem = data_freshness_policy_elem.find(".//t:freshAtSchedule", namespaces=ns) + fresh_at_schedule = DataFreshnessPolicyItem.FreshAt.from_xml_element(fresh_at_schedule_elem, ns) + data_freshness_policy.fresh_at_schedule = fresh_at_schedule + elif option == "FreshEvery": + fresh_every_schedule_elem = data_freshness_policy_elem.find(".//t:freshEverySchedule", namespaces=ns) + fresh_every_schedule = DataFreshnessPolicyItem.FreshEvery.from_xml_element(fresh_every_schedule_elem) + data_freshness_policy.fresh_every_schedule = fresh_every_schedule + + return data_freshness_policy + + +def parse_intervals(intervals_elem, frequency, ns): + interval_elems = intervals_elem.findall(".//t:intervals/t:interval", namespaces=ns) + interval = [] + for interval_elem in interval_elems: + interval.extend(interval_elem.attrib.items()) + + # No intervals expected for Day frequency + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Day: + return None + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Week: + interval_values = [(i[1]).title() for i in interval] + return parse_week_intervals(interval_values) + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + interval_values = [(i[1]) for i in interval] + return parse_month_intervals(interval_values) + + +def parse_week_intervals(interval_values): + # Using existing IntervalItem.Day to check valid weekday string + if not all(hasattr(IntervalItem.Day, day) for day in interval_values): + raise ValueError("Invalid week day defined " + str(interval_values)) + return interval_values + + +def parse_month_intervals(interval_values): + error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + + # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] + # First check if the list only have LastDay value. When using LastDay, there shouldn't be + # any other values, hence checking the first element of the list is enough. + # If the value is not "LastDay", we assume intervals is on list of dates format. + # We created this function instead of using existing MonthlyInterval because we allow list of dates interval, + + intervals = [] + if interval_values[0] == "LastDay": + intervals.append(interval_values[0]) + else: + for interval in interval_values: + try: + if 1 <= int(interval) <= 31: + intervals.append(interval) + else: + raise ValueError(error) + except ValueError: + if interval_values[0] != "LastDay": + raise ValueError(error) + return intervals diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 537e6c14f..3ee1fee08 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -136,7 +136,7 @@ def interval(self): @interval.setter def interval(self, intervals): - VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24} for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 7c801a4b5..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,7 @@ import datetime import re from functools import wraps +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -65,7 +66,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range, allowed=None): +def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -89,8 +90,10 @@ def wrapper(self, value): raise ValueError(error) min, max = range + if value in allowed: + return func(self, value) - if (value < min or value > max) and (value not in allowed): + if value < min or value > max: raise ValueError(error) return func(self, value) @@ -144,15 +147,7 @@ def property_is_data_acceleration_config(func): def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) - if len(value) != 4 or not all( - attr in value.keys() - for attr in ( - "acceleration_enabled", - "accelerate_now", - "last_updated_at", - "acceleration_status", - ) - ): + if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index db187a5f9..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -26,6 +26,7 @@ class Type: Subscription = "Subscription" DataAcceleration = "DataAcceleration" ActiveDirectorySync = "ActiveDirectorySync" + System = "System" class ExecutionOrder: Parallel = "Parallel" diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 0ffc3bfab..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -18,6 +18,7 @@ class Type: _TASK_TYPE_MAPPING = { "RefreshExtractTask": Type.ExtractRefresh, "MaterializeViewsTask": Type.DataAcceleration, + "RunFlowTask": Type.RunFlow, } def __init__( diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 90cff490b..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -31,6 +31,10 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + self._data_acceleration_config = { + "acceleration_enabled": None, + "acceleration_status": None, + } def __str__(self): return "".format( @@ -133,6 +137,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def data_acceleration_config(self): + return self._data_acceleration_config + + @data_acceleration_config.setter + def data_acceleration_config(self, value): + self._data_acceleration_config = value + @property def permissions(self) -> List[PermissionsRule]: if self._permissions is None: @@ -164,6 +176,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) tags_elem = view_xml.find(".//t:tags", namespaces=ns) + data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) view_item._id = view_xml.get("id", None) @@ -186,4 +199,25 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if data_acceleration_config_elem is not None: + data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) + view_item.data_acceleration_config = data_acceleration_config return view_item + + +def parse_data_acceleration_config(data_acceleration_elem): + data_acceleration_config = dict() + + acceleration_enabled = data_acceleration_elem.get("accelerationEnabled", None) + if acceleration_enabled is not None: + acceleration_enabled = string_to_bool(acceleration_enabled) + + acceleration_status = data_acceleration_elem.get("accelerationStatus", None) + + data_acceleration_config["acceleration_enabled"] = acceleration_enabled + data_acceleration_config["acceleration_status"] = acceleration_status + return data_acceleration_config + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 86a9a2f18..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -17,6 +17,7 @@ from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem +from .data_freshness_policy_item import DataFreshnessPolicyItem class WorkbookItem(object): @@ -34,7 +35,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None @@ -49,6 +50,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, "last_updated_at": None, "acceleration_status": None, } + self.data_freshness_policy = None self._permissions = None return None @@ -91,6 +93,10 @@ def created_at(self) -> Optional[datetime.datetime]: def description(self) -> Optional[str]: return self._description + @description.setter + def description(self, value: str): + self._description = value + @property def id(self) -> Optional[str]: return self._id @@ -162,6 +168,10 @@ def views(self) -> List[ViewItem]: # We had views included in a WorkbookItem response return self._views + @views.setter + def views(self, value): + self._views = value + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -171,6 +181,15 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def data_freshness_policy(self): + return self._data_freshness_policy + + @data_freshness_policy.setter + # @property_is_data_freshness_policy + def data_freshness_policy(self, value): + self._data_freshness_policy = value + @property def revisions(self) -> List[RevisionItem]: if self._revisions is None: @@ -217,8 +236,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, _, - _, + views, data_acceleration_config, + data_freshness_policy, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -235,8 +255,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, None, - None, + views, data_acceleration_config, + data_freshness_policy, ) return self @@ -258,6 +279,7 @@ def _set_values( tags, views, data_acceleration_config, + data_freshness_policy, ): if id is not None: self._id = id @@ -286,10 +308,12 @@ def _set_values( if tags: self.tags = tags self._initial_tags = copy.copy(tags) - if views: + if views is not None: self._views = views if data_acceleration_config is not None: self.data_acceleration_config = data_acceleration_config + if data_freshness_policy is not None: + self.data_freshness_policy = data_freshness_policy @classmethod def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: @@ -356,6 +380,11 @@ def _parse_element(workbook_xml, ns): if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) + data_freshness_policy = None + data_freshness_policy_elem = workbook_xml.find(".//t:dataFreshnessPolicy", namespaces=ns) + if data_freshness_policy_elem is not None: + data_freshness_policy = DataFreshnessPolicyItem.from_xml_element(data_freshness_policy_elem, ns) + return ( id, name, @@ -372,6 +401,7 @@ def _parse_element(workbook_xml, ns): tags, views, data_acceleration_config, + data_freshness_policy, ) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index c018d8334..b2f291369 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -10,6 +10,7 @@ from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns from .flows_endpoint import Flows +from .flow_task_endpoint import FlowTasks from .groups_endpoint import Groups from .jobs_endpoint import Jobs from .metadata_endpoint import Metadata diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 66ad9f710..28226d280 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import json import io @@ -437,14 +437,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m["Content-Disposition"] = server_response.headers["Content-Disposition"] + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py new file mode 100644 index 000000000..eea3f9710 --- /dev/null +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -0,0 +1,29 @@ +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory + +from tableauserverclient.helpers.logging import logger + +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class FlowTasks(Endpoint): + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.22") + def create(self, flow_item: TaskItem) -> TaskItem: + if not flow_item: + error = "No flow provided" + raise ValueError(error) + logger.info("Creating an flow task %s", flow_item) + url = self.baseurl + create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item) + server_response = self.post_request(url, create_req) + return server_response.content diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 21c16b1cc..77b01c478 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -120,14 +120,16 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m["Content-Disposition"] = server_response.headers["Content-Disposition"] + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 506fe02c2..bc535b2d6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -137,7 +137,12 @@ def delete(self, workbook_id: str) -> None: # Update workbook @api(version="2.0") - def update(self, workbook_item: WorkbookItem) -> WorkbookItem: + @parameter_added_in(include_view_acceleration_status="3.22") + def update( + self, + workbook_item: WorkbookItem, + include_view_acceleration_status: bool = False, + ) -> WorkbookItem: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -146,6 +151,9 @@ def update(self, workbook_item: WorkbookItem) -> WorkbookItem: # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) + if include_view_acceleration_status: + url += "?includeViewAccelerationStatus=True" + update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) @@ -483,14 +491,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m["Content-Disposition"] = server_response.headers["Content-Disposition"] + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index b65d75ae5..3220f5372 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -47,7 +47,11 @@ def __iter__(self): # Get the rest on demand as a generator while self._count < last_pagination_item.total_available: - if len(current_item_list) == 0: + if ( + len(current_item_list) == 0 + and (last_pagination_item.page_number * last_pagination_item.page_size) + < last_pagination_item.total_available + ): current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) try: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6316527ec..c204e7217 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -57,6 +57,11 @@ def _add_hiddenview_element(views_element, view_name): view_element.attrib["hidden"] = "true" +def _add_view_element(views_element, view_id): + view_element = ET.SubElement(views_element, "view") + view_element.attrib["id"] = view_id + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") if connection_credentials.password is None or connection_credentials.name is None: @@ -911,6 +916,9 @@ def _generate_xml( for connection in connections: _add_connections_element(connections_element, connection) + if workbook_item.description is not None: + workbook_element.attrib["description"] = workbook_item.description + if hidden_views is not None: import warnings @@ -941,16 +949,61 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, "owner") owner_element.attrib["id"] = workbook_item.owner_id - if workbook_item.data_acceleration_config["acceleration_enabled"] is not None: + if workbook_item._views is not None: + views_element = ET.SubElement(workbook_element, "views") + for view in workbook_item.views: + _add_view_element(views_element, view.id) + if workbook_item.data_acceleration_config: data_acceleration_config = workbook_item.data_acceleration_config data_acceleration_element = ET.SubElement(workbook_element, "dataAccelerationConfig") - data_acceleration_element.attrib["accelerationEnabled"] = str( - data_acceleration_config["acceleration_enabled"] - ).lower() + if data_acceleration_config["acceleration_enabled"] is not None: + data_acceleration_element.attrib["accelerationEnabled"] = str( + data_acceleration_config["acceleration_enabled"] + ).lower() if data_acceleration_config["accelerate_now"] is not None: data_acceleration_element.attrib["accelerateNow"] = str( data_acceleration_config["accelerate_now"] ).lower() + if workbook_item.data_freshness_policy is not None: + data_freshness_policy_config = workbook_item.data_freshness_policy + data_freshness_policy_element = ET.SubElement(workbook_element, "dataFreshnessPolicy") + data_freshness_policy_element.attrib["option"] = str(data_freshness_policy_config.option) + # Fresh Every Schedule + if data_freshness_policy_config.option == "FreshEvery": + if data_freshness_policy_config.fresh_every_schedule is not None: + fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) + else: + raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") + # Fresh At Schedule + if data_freshness_policy_config.option == "FreshAt": + if data_freshness_policy_config.fresh_at_schedule is not None: + fresh_at_element = ET.SubElement(data_freshness_policy_element, "freshAtSchedule") + frequency = data_freshness_policy_config.fresh_at_schedule.frequency + fresh_at_element.attrib["frequency"] = frequency + fresh_at_element.attrib["time"] = str(data_freshness_policy_config.fresh_at_schedule.time) + fresh_at_element.attrib["timezone"] = str(data_freshness_policy_config.fresh_at_schedule.timezone) + intervals = data_freshness_policy_config.fresh_at_schedule.interval_item + # Fresh At Schedule intervals if Frequency is Week or Month + if frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + if intervals is not None: + # if intervals is not None or frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + intervals_element = ET.SubElement(fresh_at_element, "intervals") + for interval in intervals: + expression = IntervalItem.Occurrence.WeekDay + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + expression = IntervalItem.Occurrence.MonthDay + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = interval + else: + raise ValueError( + f"fresh_at_schedule.interval_item must be populated for " f"Week & Month frequency." + ) + else: + raise ValueError(f"data_freshness_policy_config.fresh_at_schedule must be populated.") return ET.tostring(xml_request) @@ -1061,6 +1114,40 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) +class FlowTaskRequest(object): + @_tsrequest_wrapped + def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: + flow_element = ET.SubElement(xml_request, "runFlow") + + # Main attributes + flow_element.attrib["type"] = flow_item.task_type + + if flow_item.target is not None: + target_element = ET.SubElement(flow_element, flow_item.target.type) + target_element.attrib["id"] = flow_item.target.id + + if flow_item.schedule_item is None: + return ET.tostring(xml_request) + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = flow_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + frequency_element.attrib["end"] = str(interval_item.end_time) + if hasattr(interval_item, "interval") and interval_item.interval: + intervals_element = ET.SubElement(frequency_element, "intervals") + for interval in interval_item._interval_type_pairs(): # type: ignore + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + return ET.tostring(xml_request) + + class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: @@ -1200,6 +1287,7 @@ class RequestFactory(object): Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() + FlowTask = FlowTaskRequest() Group = GroupRequest() Metric = MetricRequest() Permission = PermissionRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 95233f8fc..5cc06bf9d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,7 @@ +import sys + +from typing_extensions import Self + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -152,17 +156,27 @@ class _FilterOptionsBase(RequestOptionsBase): def __init__(self): self.view_filters = [] + self.view_parameters = [] def get_query_params(self): raise NotImplementedError() - def vf(self, name, value): + def vf(self, name: str, value: str) -> Self: + """Apply a filter to the view for a filter that is a normal column + within the view.""" self.view_filters.append((name, value)) return self - def _append_view_filters(self, params): + def parameter(self, name: str, value: str) -> Self: + """Apply a filter based on a parameter within the workbook.""" + self.view_parameters.append((name, value)) + return self + + def _append_view_filters(self, params) -> None: for name, value in self.view_filters: params["vf_" + name] = value + for name, value in self.view_parameters: + params[name] = value class CSVRequestOptions(_FilterOptionsBase): @@ -261,11 +275,13 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None, maxage=-1): + def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation self.max_age = maxage + self.viz_height = viz_height + self.viz_width = viz_width @property def max_age(self): @@ -276,6 +292,24 @@ def max_age(self): def max_age(self, value): self._max_age = value + @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): params = {} if self.page_type: @@ -287,6 +321,16 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + # 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 + self._append_view_filters(params) return params diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ee23789b1..3a6831458 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -25,6 +25,7 @@ Databases, Tables, Flows, + FlowTasks, Webhooks, DataAccelerationReport, Favorites, @@ -82,6 +83,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.datasources = Datasources(self) self.favorites = Favorites(self) self.flows = Flows(self) + self.flow_tasks = FlowTasks(self) self.projects = Projects(self) self.schedules = Schedules(self) self.server_info = ServerInfo(self) diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml index 66e4d6e51..db5e1a05e 100644 --- a/test/assets/schedule_get.xml +++ b/test/assets/schedule_get.xml @@ -5,5 +5,6 @@ + \ No newline at end of file diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml new file mode 100644 index 000000000..11c9a4ff0 --- /dev/null +++ b/test/assets/tasks_create_flow_task.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/tasks_with_interval.xml b/test/assets/tasks_with_interval.xml new file mode 100644 index 000000000..a317408fb --- /dev/null +++ b/test/assets/tasks_with_interval.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_get_by_id_acceleration_status.xml b/test/assets/workbook_get_by_id_acceleration_status.xml new file mode 100644 index 000000000..0d1f9b93d --- /dev/null +++ b/test/assets/workbook_get_by_id_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_publish.xml b/test/assets/workbook_publish.xml index dcfc79936..3e23bda71 100644 --- a/test/assets/workbook_publish.xml +++ b/test/assets/workbook_publish.xml @@ -1,6 +1,6 @@ - + @@ -8,4 +8,4 @@ - \ No newline at end of file + diff --git a/test/assets/workbook_update_acceleration_status.xml b/test/assets/workbook_update_acceleration_status.xml new file mode 100644 index 000000000..7c3366fee --- /dev/null +++ b/test/assets/workbook_update_acceleration_status.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy.xml b/test/assets/workbook_update_data_freshness_policy.xml new file mode 100644 index 000000000..a69a097ba --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy2.xml b/test/assets/workbook_update_data_freshness_policy2.xml new file mode 100644 index 000000000..384f79ec0 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy2.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy3.xml b/test/assets/workbook_update_data_freshness_policy3.xml new file mode 100644 index 000000000..195013517 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy3.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy4.xml b/test/assets/workbook_update_data_freshness_policy4.xml new file mode 100644 index 000000000..8208d986a --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy4.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy5.xml b/test/assets/workbook_update_data_freshness_policy5.xml new file mode 100644 index 000000000..b6e0358b6 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy5.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy6.xml b/test/assets/workbook_update_data_freshness_policy6.xml new file mode 100644 index 000000000..c8be8f6c1 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy6.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_views_acceleration_status.xml b/test/assets/workbook_update_views_acceleration_status.xml new file mode 100644 index 000000000..f2055fb79 --- /dev/null +++ b/test/assets/workbook_update_views_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_data_freshness_policy.py b/test/test_data_freshness_policy.py new file mode 100644 index 000000000..9591a6380 --- /dev/null +++ b/test/test_data_freshness_policy.py @@ -0,0 +1,189 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +UPDATE_DFP_ALWAYS_LIVE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy.xml") +UPDATE_DFP_SITE_DEFAULT_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy2.xml") +UPDATE_DFP_FRESH_EVERY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy3.xml") +UPDATE_DFP_FRESH_AT_DAILY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy4.xml") +UPDATE_DFP_FRESH_AT_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy5.xml") +UPDATE_DFP_FRESH_AT_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy6.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_update_DFP_always_live(self) -> None: + with open(UPDATE_DFP_ALWAYS_LIVE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("AlwaysLive", single_workbook.data_freshness_policy.option) + + def test_update_DFP_site_default(self) -> None: + with open(UPDATE_DFP_SITE_DEFAULT_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("SiteDefault", single_workbook.data_freshness_policy.option) + + def test_update_DFP_fresh_every(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshEvery", single_workbook.data_freshness_policy.option) + self.assertEqual("Hours", single_workbook.data_freshness_policy.fresh_every_schedule.frequency) + self.assertEqual(10, single_workbook.data_freshness_policy.fresh_every_schedule.value) + + def test_update_DFP_fresh_every_missing_attributes(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_day(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Day", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("22:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Asia/Singapore", single_workbook.data_freshness_policy.fresh_at_schedule.timezone) + + def test_update_DFP_fresh_at_week(self) -> None: + with open(UPDATE_DFP_FRESH_AT_WEEKLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "10:00:00", + "America/Los_Angeles", + ["Monday", "Wednesday"], + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Week", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("10:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Wednesday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + self.assertEqual("Monday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1]) + + def test_update_DFP_fresh_at_month(self) -> None: + with open(UPDATE_DFP_FRESH_AT_MONTHLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Month", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("00:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("LastDay", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + + def test_update_DFP_fresh_at_missing_params(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_missing_interval(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) diff --git a/test/test_flowtask.py b/test/test_flowtask.py new file mode 100644 index 000000000..034066e64 --- /dev/null +++ b/test/test_flowtask.py @@ -0,0 +1,47 @@ +import os +import unittest +from datetime import time +from pathlib import Path + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.task_item import TaskItem + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") + + +class TaskTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake Signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.flow_tasks.baseurl + + def test_create_flow_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + "Monthly Schedule", + 50, + TSC.ScheduleItem.Type.Flow, + TSC.ScheduleItem.ExecutionOrder.Parallel, + monthly_interval, + ) + target_item = TSC.Target("flow_id", "flow") + + task = TaskItem(None, "RunFlow", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post("{}".format(self.baseurl), text=response_xml) + create_response_content = self.server.flow_tasks.create(task).decode("utf-8") + + self.assertTrue("schedule_id" in create_response_content) + self.assertTrue("flow_id" in create_response_content) diff --git a/test/test_request_option.py b/test/test_request_option.py index 32526d1e6..40dd3345a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -2,6 +2,7 @@ from pathlib import Path import re import unittest +from urllib.parse import parse_qs import requests_mock @@ -311,3 +312,22 @@ def test_slicing_queryset_multi_page(self) -> None: def test_queryset_filter_args_error(self) -> None: with self.assertRaises(RuntimeError): workbooks = self.server.workbooks.filter("argument") + + def test_filtering_parameters(self) -> None: + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.parameter("name1@", "value1") + opts.parameter("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = self.server.workbooks.get_request(url, request_object=opts) + query_params = parse_qs(resp.request.query) + self.assertIn("name1@", query_params) + self.assertIn("value1", query_params["name1@"]) + self.assertIn("name2$", query_params) + self.assertIn("value2", query_params["name2$"]) + self.assertIn("type", query_params) + self.assertIn("tabloid", query_params["type"]) diff --git a/test/test_schedule.py b/test/test_schedule.py index 76c8720b9..3bbf5709b 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -50,6 +50,7 @@ def test_get(self) -> None: extract = all_schedules[0] subscription = all_schedules[1] flow = all_schedules[2] + system = all_schedules[3] self.assertEqual(2, pagination_item.total_available) self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) @@ -79,6 +80,15 @@ def test_get(self) -> None: self.assertEqual("Flow", flow.schedule_type) self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) + self.assertEqual("3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04", system.id) + self.assertEqual("First of the month 2:00AM", system.name) + self.assertEqual("Active", system.state) + self.assertEqual(30, system.priority) + self.assertEqual("2019-02-19T18:52:19Z", format_datetime(system.created_at)) + self.assertEqual("2019-02-19T18:55:51Z", format_datetime(system.updated_at)) + self.assertEqual("System", system.schedule_type) + self.assertEqual("2019-03-01T09:00:00Z", format_datetime(system.next_run_at)) + def test_get_empty(self) -> None: with open(GET_EMPTY_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_task.py b/test/test_task.py index 4e0157dfd..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -19,6 +19,7 @@ GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" +GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" class TaskTests(unittest.TestCase): @@ -97,6 +98,15 @@ def test_get_task_without_schedule(self): self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) self.assertEqual("datasource", task.target.type) + def test_get_task_with_interval(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_XML_WITH_INTERVAL.read_text()) + all_tasks, pagination_item = self.server.tasks.get() + + task = all_tasks[0] + self.assertEqual("e4de0575-fcc7-4232-5659-be09bb8e7654", task.target.id) + self.assertEqual("datasource", task.target.type) + def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) diff --git a/test/test_view.py b/test/test_view.py index 1459150bb..720a0ce64 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -315,3 +315,32 @@ def test_filter_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(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, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.views.populate_pdf(single_view, req_option) + self.assertEqual(response, single_view.pdf) + + def test_pdf_errors(self) -> None: + req_option = TSC.PDFRequestOptions(viz_height=1080) + with self.assertRaises(ValueError): + req_option.get_query_params() + req_option = TSC.PDFRequestOptions(viz_width=1920) + with self.assertRaises(ValueError): + req_option.get_query_params() diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py new file mode 100644 index 000000000..6f94f0c10 --- /dev/null +++ b/test/test_view_acceleration.py @@ -0,0 +1,119 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_BY_ID_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_acceleration_status.xml") +POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") +UPDATE_VIEWS_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_views_acceleration_status.xml") +UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_acceleration_status.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_get_by_id(self) -> None: + with open(GET_BY_ID_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id) + self.assertEqual("SafariSample", single_workbook.name) + self.assertEqual("SafariSample", single_workbook.content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) + self.assertEqual(False, single_workbook.show_tabs) + self.assertEqual(26, single_workbook.size) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) + self.assertEqual("description for SafariSample", single_workbook.description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) + self.assertEqual("default", single_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) + self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Enabled", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(False, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Suspended", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_workbook_acceleration(self) -> None: + with open(UPDATE_WORKBOOK_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(True, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_views_acceleration(self) -> None: + with open(POPULATE_VIEWS_XML, "rb") as f: + views_xml = f.read().decode("utf-8") + with open(UPDATE_VIEWS_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=views_xml) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": False, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + self.server.workbooks.populate_views(single_workbook) + single_workbook.views = [single_workbook.views[1], single_workbook.views[2]] + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + views_list = single_workbook.views + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) + self.assertEqual("GDP per capita", views_list[0].name) + self.assertEqual(False, views_list[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Disabled", views_list[0].data_acceleration_config["acceleration_status"]) + + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) + self.assertEqual("Country ranks", views_list[1].name) + self.assertEqual(True, views_list[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[1].data_acceleration_config["acceleration_status"]) + + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) + self.assertEqual("Interest rates", views_list[2].name) + self.assertEqual(True, views_list[2].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[2].data_acceleration_config["acceleration_status"]) diff --git a/test/test_workbook.py b/test/test_workbook.py index 212d55a37..ac3d44b28 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -488,6 +488,8 @@ def test_publish(self) -> None: name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" ) + new_workbook.description = "REST API Testing" + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew @@ -506,6 +508,7 @@ def test_publish(self) -> None: self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) self.assertEqual("GDP per capita", new_workbook.views[0].name) self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + self.assertEqual("REST API Testing", new_workbook.description) def test_publish_a_packaged_file_object(self) -> None: with open(PUBLISH_XML, "rb") as f: From ffcb78601c13ebd0dca39a9b063ffc8776fe7d0f Mon Sep 17 00:00:00 2001 From: "ivan.baldinotti@digitecgalaxus.ch" Date: Wed, 5 Jun 2024 20:04:50 +0200 Subject: [PATCH 399/567] Adding name property to jobItem Class --- tableauserverclient/models/job_item.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 61e7a8d18..39f22bf03 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -33,6 +33,7 @@ def __init__( datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, updated_at: Optional[datetime.datetime] = None, + workbook_name: Optional[str] = None, ): self._id = id_ self._type = job_type @@ -47,6 +48,7 @@ def __init__( self._datasource_id = datasource_id self._flow_run = flow_run self._updated_at = updated_at + self._workbook_name = workbook_name @property def id(self) -> str: @@ -117,6 +119,10 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at + @property + def workbook_name(self) -> Optional[str]: + return self._workbook_name + def __str__(self): return ( " Date: Thu, 6 Jun 2024 08:37:48 +0200 Subject: [PATCH 400/567] Adding test for workbook name --- test/test_job.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_job.py b/test/test_job.py index 83edadaef..befed08b4 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -120,3 +120,11 @@ def test_get_job_workbook_id(self) -> None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") + + def test_get_job_workbook_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.workbook_name, "Superstore") From 4feeffda8829bc73d612b92ce7c459bb4282f7e5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:06:32 -0500 Subject: [PATCH 401/567] chore: remove deprecated group update argument --- .../server/endpoint/groups_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ab5f672d1..148151d12 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -93,7 +93,7 @@ def update( elif as_job: url = "?".join([url, "asJob=True"]) - update_req = RequestFactory.Group.update_req(group_item, None) + update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c204e7217..6ebd08dd1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -418,19 +418,7 @@ def create_ad_req(self, group_item: GroupItem) -> bytes: import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item: GroupItem, default_site_role: Optional[str] = None) -> bytes: - # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - + def update_req(self, group_item: GroupItem, ) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") From 1b7eb9b244a5dd1deb095cb9783206ae905aed85 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:07:37 -0500 Subject: [PATCH 402/567] chore: remove deprecated workbook method --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bc535b2d6..0eb7115f4 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -160,13 +160,6 @@ def update( updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) - @api(version="2.3") - def update_conn(self, *args, **kwargs): - import warnings - - warnings.warn("update_conn is deprecated, please use update_connection instead") - return self.update_connection(*args, **kwargs) - # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: From 7a7587dee5e53314d1f658c13eee62bd819d1551 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:08:01 -0500 Subject: [PATCH 403/567] chore: remove deprecated workbook publish arguments --- .../server/endpoint/workbooks_endpoint.py | 15 ---- tableauserverclient/server/request_factory.py | 27 ------ test/test_workbook.py | 82 ------------------- 3 files changed, 124 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 0eb7115f4..8a5b7a112 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -308,21 +308,12 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connection_credentials: Optional["ConnectionCredentials"] = None, connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, parameters=None, ): - if connection_credentials is not None: - import warnings - - warnings.warn( - "connection_credentials is being deprecated. Use connections instead", - DeprecationWarning, - ) - if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -384,12 +375,9 @@ def publish( logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) else: logger.info("Publishing {0} to server".format(filename)) @@ -404,14 +392,11 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req( workbook_item, filename, file_contents, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6ebd08dd1..68889d4e5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -881,9 +881,7 @@ class WorkbookRequest(object): def _generate_xml( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") @@ -893,12 +891,6 @@ def _generate_xml( project_element = ET.SubElement(workbook_element, "project") project_element.attrib["id"] = str(workbook_item.project_id) - if connection_credentials is not None and connections is not None: - raise RuntimeError("You cannot set both `connections` and `connection_credentials`") - - if connection_credentials is not None and connection_credentials != False: - _add_credentials_element(workbook_element, connection_credentials) - if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: @@ -907,17 +899,6 @@ def _generate_xml( if workbook_item.description is not None: workbook_element.attrib["description"] = workbook_item.description - if hidden_views is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - "the hidden_views parameter should now be set on the workbook directly", - DeprecationWarning, - ) - if workbook_item.hidden_views is None: - workbook_item.hidden_views = hidden_views - if workbook_item.hidden_views is not None: views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: @@ -1000,15 +981,11 @@ def publish_req( workbook_item, filename, file_contents, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = { @@ -1020,15 +997,11 @@ def publish_req( def publish_req_chunked( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = {"request_payload": ("", xml_request, "text/xml")} diff --git a/test/test_workbook.py b/test/test_workbook.py index ac3d44b28..991af2ad8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -621,31 +621,6 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) - # this tests the old method of including workbook views as a parameter for publishing - # should be removed when that functionality is removed - # see https://github.com/tableau/server-client-python/pull/617 - def test_publish_with_hidden_view(self) -> None: - with open(PUBLISH_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_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish( - new_workbook, sample_workbook, publish_mode, hidden_views=["GDP per capita"] - ) - - request_body = m._adapter.request_history[0]._request.body - # order of attributes in xml is unspecified - self.assertTrue(re.search(rb"<\/views>", request_body)) - self.assertTrue(re.search(rb"<\/views>", request_body)) - def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -775,63 +750,6 @@ def test_publish_multi_connection_flat(self) -> None: self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - def test_publish_single_connection(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "test") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_publish_single_connection_username_none(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials(None, "secret", True) - - self.assertRaises( - ValueError, - RequestFactory.Workbook._generate_xml, - new_workbook, - connection_credentials=connection_creds, - ) - - def test_publish_single_connection_username_empty(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_credentials_and_multi_connect_raises_exception(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - with self.assertRaises(RuntimeError): - response = RequestFactory.Workbook._generate_xml( - new_workbook, connection_credentials=connection_creds, connections=[connection1] - ) - def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: m.register_uri("POST", self.baseurl, status_code=504) From c2ab2bef78f92b04a977ae582d2974aeb3ee8ba3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:10:52 -0500 Subject: [PATCH 404/567] style: black --- tableauserverclient/server/request_factory.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 68889d4e5..fe268892a 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -418,7 +418,10 @@ def create_ad_req(self, group_item: GroupItem) -> bytes: import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item: GroupItem, ) -> bytes: + def update_req( + self, + group_item: GroupItem, + ) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") From 2c4b7871cd39df1fbf2336e05ff886b799aa36eb Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:25:32 -0500 Subject: [PATCH 405/567] chore: remove other deprecated methods --- .../server/endpoint/databases_endpoint.py | 11 ----------- .../server/endpoint/datasources_endpoint.py | 11 ----------- .../server/endpoint/flows_endpoint.py | 10 ---------- .../server/endpoint/groups_endpoint.py | 13 +------------ .../server/endpoint/projects_endpoint.py | 11 ----------- .../server/endpoint/tables_endpoint.py | 10 ---------- 6 files changed, 1 insertion(+), 65 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 125996277..849072a17 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -88,17 +88,6 @@ def _get_tables_for_database(self, database_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.databases.update_permission is deprecated, " - "please use Server.databases.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 28226d280..3991456de 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -351,17 +351,6 @@ def update_hyper_data( def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.datasources.update_permission is deprecated, " - "please use Server.datasources.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 77b01c478..a2d68a0d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -265,16 +265,6 @@ def publish( def populate_permissions(self, item: FlowItem) -> None: self._permissions.populate(item) - @api(version="3.3") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.flows.update_permission is deprecated, " "please use Server.flows.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 148151d12..40e649c21 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -68,20 +68,9 @@ def delete(self, group_id: str) -> None: @api(version="2.0") def update( - self, group_item: GroupItem, default_site_role: Optional[str] = None, as_job: bool = False + self, group_item: GroupItem, as_job: bool = False ) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'Groups.update(...default_site_role=""...) is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 99bb2e39b..b56a480ec 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -75,17 +75,6 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.projects.update_permission is deprecated, " - "please use Server.projects.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="2.0") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index dfb2e6d7c..b4c5181e9 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -101,16 +101,6 @@ def update_column(self, table_item, column_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.tables.update_permission is deprecated, " "please use Server.tables.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) From 98d27b4357e43af04c83bb46473c515adbc7f75b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:27:54 -0500 Subject: [PATCH 406/567] chore: black --- tableauserverclient/server/endpoint/groups_endpoint.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 40e649c21..286e8126c 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -67,9 +67,7 @@ def delete(self, group_id: str) -> None: logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") - def update( - self, group_item: GroupItem, as_job: bool = False - ) -> Union[GroupItem, JobItem]: + def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 url = "{0}/{1}".format(self.baseurl, group_item.id) From 560fff82b68957ab37c99b7408b80d6c7cf619b0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:30:43 -0500 Subject: [PATCH 407/567] chore: remove comment --- tableauserverclient/server/endpoint/groups_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 286e8126c..35f17e53b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -68,7 +68,6 @@ def delete(self, group_id: str) -> None: @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - # (1/8/2021): Deprecated starting v0.15 url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: From 83233a56bfd27d0439a6a60b88129a2290b24ec7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 19:51:11 -0500 Subject: [PATCH 408/567] chore: remove hidden_views from wb publish --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8a5b7a112..e74329a35 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -310,7 +310,6 @@ def publish( mode: str, connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, - hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, parameters=None, ): From d84adecd30cd89604cb475f2749b2ac49d13b08e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 06:46:43 -0500 Subject: [PATCH 409/567] chore: remove deprecated site arg in auth --- tableauserverclient/models/tableau_auth.py | 15 +-------------- test/test_tableauauth_model.py | 8 -------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 9aca206d7..8cb2a8848 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -28,10 +28,7 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): - if site is not None: - deprecate_site_attribute() - site_id = site + def __init__(self, username, password, site_id=None, user_id_to_impersonate=None): super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -49,16 +46,6 @@ def __repr__(self): uid = "" return f"" - @property - def site(self): - deprecate_site_attribute() - return self.site_id - - @site.setter - def site(self, value): - deprecate_site_attribute() - self.site_id = value - # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index e8ae242d9..f1deed6a3 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,5 +1,4 @@ import unittest -import warnings import tableauserverclient as TSC @@ -12,10 +11,3 @@ def test_username_password_required(self): with self.assertRaises(TypeError): TSC.TableauAuth() - def test_site_arg_raises_warning(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - - tableau_auth = TSC.TableauAuth("user", "password", site="Default") - - self.assertTrue(any(item.category == DeprecationWarning for item in w)) From 58bc727539a4ff0d89a41411a3722894c5a85ee9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 06:47:42 -0500 Subject: [PATCH 410/567] chore: remove no_extract arg from workbook download --- .../server/endpoint/workbooks_endpoint.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e74329a35..b5ac80982 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -182,9 +182,8 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(workbook_id, None, filepath, include_extract, no_extract) + return self.download_revision(workbook_id, None, filepath, include_extract, ) # Get all views of workbook @api(version="2.0") @@ -445,7 +444,6 @@ def download_revision( revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not workbook_id: error = "Workbook ID undefined." @@ -455,15 +453,6 @@ def download_revision( else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract - if not include_extract: url += "?includeExtract=False" From dd04bbd9f195dd1cee1604bebe5cfb9f25afb727 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:28:03 -0500 Subject: [PATCH 411/567] style: black --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 7 ++++++- test/test_tableauauth_model.py | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index b5ac80982..1cec71c08 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -183,7 +183,12 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> str: - return self.download_revision(workbook_id, None, filepath, include_extract, ) + return self.download_revision( + workbook_id, + None, + filepath, + include_extract, + ) # Get all views of workbook @api(version="2.0") diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index f1deed6a3..195bcf0a9 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -10,4 +10,3 @@ def setUp(self): def test_username_password_required(self): with self.assertRaises(TypeError): TSC.TableauAuth() - From 281ae3e1763e11e09f0de1111710dd39bebd7e7e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:35:44 -0500 Subject: [PATCH 412/567] chore: remove deprecated arg from download datasource --- .../server/endpoint/datasources_endpoint.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 3991456de..69f6f9747 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -126,9 +126,13 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(datasource_id, None, filepath, include_extract, no_extract) + return self.download_revision( + datasource_id, + None, + filepath, + include_extract, + ) # Update datasource @api(version="2.0") @@ -404,7 +408,6 @@ def download_revision( revision_number: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not datasource_id: error = "Datasource ID undefined." @@ -413,14 +416,6 @@ def download_revision( url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract if not include_extract: url += "?includeExtract=False" From 9daf4e3e1b7c2f73b967c3b6822233b456765308 Mon Sep 17 00:00:00 2001 From: "ivan.baldinotti@digitecgalaxus.ch" Date: Fri, 7 Jun 2024 17:42:35 +0200 Subject: [PATCH 413/567] Adding datasource name attribute to job item --- tableauserverclient/models/job_item.py | 8 ++++++++ test/test_job.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 39f22bf03..9933d7f29 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -34,6 +34,7 @@ def __init__( flow_run: Optional[FlowRunItem] = None, updated_at: Optional[datetime.datetime] = None, workbook_name: Optional[str] = None, + datasource_name: Optional[str] = None, ): self._id = id_ self._type = job_type @@ -49,6 +50,7 @@ def __init__( self._flow_run = flow_run self._updated_at = updated_at self._workbook_name = workbook_name + self._datasource_name = datasource_name @property def id(self) -> str: @@ -123,6 +125,10 @@ def updated_at(self) -> Optional[datetime.datetime]: def workbook_name(self) -> Optional[str]: return self._workbook_name + @property + def datasource_name(self) -> Optional[str]: + return self._datasource_name + def __str__(self): return ( " None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.workbook_name, "Superstore") + + def test_get_job_datasource_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.datasource_name, "World Indicators") From e6900e0636cb2f7f4fb36b89a01bd405c959a26c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 11 Jun 2024 08:38:21 -0500 Subject: [PATCH 414/567] fix: don't lowercase OData server addresses Closes #1392 OData strings are case sensitive. If the ConnectionItem has a connection_type indicating it is an OData connection, do not force the server address of the ConnectionItem to lowercase. --- tableauserverclient/server/request_factory.py | 9 ++++-- test/assets/odata_connection.xml | 7 +++++ test/test_workbook.py | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 test/assets/odata_connection.xml diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c204e7217..1336576b5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1061,8 +1061,13 @@ class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") - if connection_item.server_address is not None: - connection_element.attrib["serverAddress"] = connection_item.server_address.lower() + if (server_address := connection_item.server_address) is not None: + if (conn_type := connection_item.connection_type) is not None: + if conn_type.casefold() != "odata".casefold(): + server_address = server_address.lower() + else: + server_address = server_address.lower() + connection_element.attrib["serverAddress"] = server_address if connection_item.server_port is not None: connection_element.attrib["serverPort"] = str(connection_item.server_port) if connection_item.username is not None: diff --git a/test/assets/odata_connection.xml b/test/assets/odata_connection.xml new file mode 100644 index 000000000..0c16fcca6 --- /dev/null +++ b/test/assets/odata_connection.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index ac3d44b28..025fc55ab 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -22,6 +22,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_POWERPOINT = os.path.join(TEST_ASSET_DIR, "populate_powerpoint.pptx") @@ -944,3 +945,31 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_odata_connection(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + url = "https://odata.website.com/TestODataEndpoint" + connection.server_address = url + connection._connection_type = "odata" + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + + creds = TSC.ConnectionCredentials("", "", True) + connection.connection_credentials = creds + with open(ODATA_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + self.server.workbooks.update_connection(workbook, connection) + + history = m.request_history + + request = history[0] + xml = fromstring(request.body) + xml_connection = xml.find(".//connection") + + assert xml_connection is not None + self.assertEqual(xml_connection.get("serverAddress"), url) From b1b387355e3a90e2ac031a21747f29d8b7b8046a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:34:37 -0500 Subject: [PATCH 415/567] feat: add size to datasource item --- tableauserverclient/models/datasource_item.py | 12 ++++++++++++ test/assets/datasource_get.xml | 6 +++--- test/test_datasource.py | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5a867135c..fb2db6663 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -47,6 +47,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None + self._size: Optional[int] = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None @@ -182,6 +183,10 @@ def revisions(self) -> List[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def size(self) -> Optional[int]: + return self._size + def _set_connections(self, connections): self._connections = connections @@ -217,6 +222,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -237,6 +243,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) return self @@ -260,6 +267,7 @@ def _set_values( updated_at, use_remote_query_agent, webpage_url, + size, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -297,6 +305,8 @@ def _set_values( self._use_remote_query_agent = str(use_remote_query_agent).lower() == "true" if webpage_url: self._webpage_url = webpage_url + if size is not None: + self._size = int(size) @classmethod def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: @@ -330,6 +340,7 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: has_extracts = datasource_xml.get("hasExtracts", None) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) + size = datasource_xml.get("size", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -372,4 +383,5 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at, use_remote_query_agent, webpage_url, + size, ) diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml index 951409caa..1c420d116 100644 --- a/test/assets/datasource_get.xml +++ b/test/assets/datasource_get.xml @@ -2,12 +2,12 @@ - + - + @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/test/test_datasource.py b/test/test_datasource.py index f258fdc52..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -52,6 +52,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[0].datasource_type) self.assertEqual("SampleDsDescription", all_datasources[0].description) self.assertEqual("SampleDS", all_datasources[0].content_url) + self.assertEqual(4096, all_datasources[0].size) self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at)) self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at)) self.assertEqual("default", all_datasources[0].project_name) @@ -67,6 +68,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[1].datasource_type) self.assertEqual("description Sample", all_datasources[1].description) self.assertEqual("Sampledatasource", all_datasources[1].content_url) + self.assertEqual(10240, all_datasources[1].size) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at)) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at)) self.assertEqual("default", all_datasources[1].project_name) From 30100a07dd1b235dacd05c9fc629d8316b2daf61 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 14 Jun 2024 08:54:51 -0500 Subject: [PATCH 416/567] chore: remove outdated dependencies argparse and mock were listed as test dependencies, but both packages are part of the python standard library and do not need to be installed. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fceb37237..062e84109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] From 8c8b88cc4abaf7ed3dd9f1c1ee5da9bd47f8f45d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:05:31 -0500 Subject: [PATCH 417/567] ci: add dependency for build --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 062e84109..402b735b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] From 71697916df84856c9f5408b2496b1702f9b095dc Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 9 Jun 2024 12:58:39 -0500 Subject: [PATCH 418/567] chore: no implicit reexport Mypy has a setting to check for implicit reexports. In separate testing I have found that explicit exports, like what is used in this commit, make it easier for language servers to detect what is actually exported by the library which makes use by end users easier. PEP8 specifies that "relative imports for intra-package imports are highly discouraged. Always use the absolute package path for all imports." This commit also makes imports absolute to comply with PEP8. --- pyproject.toml | 2 + tableauserverclient/__init__.py | 68 +++++++++- tableauserverclient/models/__init__.py | 125 ++++++++++++------ tableauserverclient/server/__init__.py | 91 +++++++++++-- .../server/endpoint/__init__.py | 89 +++++++++---- tableauserverclient/server/server.py | 11 +- 6 files changed, 303 insertions(+), 83 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 402b735b4..1ecb01f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ disable_error_code = [ files = ["tableauserverclient", "test"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types +no_implicit_reexport = true + [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index f093f521b..91205d810 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ -from ._version import get_versions -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ( +from tableauserverclient._version import get_versions +from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE +from tableauserverclient.models import ( BackgroundJobItem, ColumnItem, ConnectionCredentials, @@ -43,7 +43,8 @@ WeeklyInterval, WorkbookItem, ) -from .server import ( + +from tableauserverclient.server import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, @@ -57,3 +58,62 @@ Server, Sort, ) + +__all__ = [ + "get_versions", + "DEFAULT_NAMESPACE", + "BackgroundJobItem", + "BackgroundJobItem", + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "CustomViewItem", + "DQWItem", + "DailyInterval", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "FavoriteItem", + "FlowItem", + "FlowRunItem", + "FileuploadItem", + "GroupItem", + "HourlyInterval", + "IntervalItem", + "JobItem", + "JWTAuth", + "MetricItem", + "MonthlyInterval", + "PaginationItem", + "Permission", + "PermissionsRule", + "PersonalAccessTokenAuth", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "SiteItem", + "ServerInfoItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WeeklyInterval", + "WorkbookItem", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "MissingRequiredFieldError", + "NotSignedInError", + "ServerResponseError", + "Filter", + "Pager", + "Server", + "Sort", +] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e7a853d9a..5fdf3c2c3 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,43 +1,94 @@ -from .column_item import ColumnItem -from .connection_credentials import ConnectionCredentials -from .connection_item import ConnectionItem -from .custom_view_item import CustomViewItem -from .data_acceleration_report_item import DataAccelerationReportItem -from .data_alert_item import DataAlertItem -from .database_item import DatabaseItem -from .data_freshness_policy_item import DataFreshnessPolicyItem -from .datasource_item import DatasourceItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .favorites_item import FavoriteItem -from .fileupload_item import FileuploadItem -from .flow_item import FlowItem -from .flow_run_item import FlowRunItem -from .group_item import GroupItem -from .interval_item import ( +from tableauserverclient.models.column_item import ColumnItem +from tableauserverclient.models.connection_credentials import ConnectionCredentials +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.custom_view_item import CustomViewItem +from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models.data_alert_item import DataAlertItem +from tableauserverclient.models.database_item import DatabaseItem +from tableauserverclient.models.data_freshness_policy_item import DataFreshnessPolicyItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.favorites_item import FavoriteItem +from tableauserverclient.models.fileupload_item import FileuploadItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.flow_run_item import FlowRunItem +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval, ) -from .job_item import JobItem, BackgroundJobItem -from .metric_item import MetricItem -from .pagination_item import PaginationItem -from .permissions_item import PermissionsRule, Permission -from .project_item import ProjectItem -from .revision_item import RevisionItem -from .schedule_item import ScheduleItem -from .server_info_item import ServerInfoItem -from .site_item import SiteItem -from .subscription_item import SubscriptionItem -from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth -from .tableau_types import Resource, TableauItem, plural_type -from .tag_item import TagItem -from .target import Target -from .task_item import TaskItem -from .user_item import UserItem -from .view_item import ViewItem -from .webhook_item import WebhookItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.permissions_item import PermissionsRule, Permission +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.server_info_item import ServerInfoItem +from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.subscription_item import SubscriptionItem +from tableauserverclient.models.table_item import TableItem +from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth +from tableauserverclient.models.tableau_types import Resource, TableauItem, plural_type +from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.target import Target +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.webhook_item import WebhookItem +from tableauserverclient.models.workbook_item import WorkbookItem + +__all__ = [ + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "Credentials", + "CustomViewItem", + "DataAccelerationReportItem", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "DQWItem", + "UnpopulatedPropertyError", + "FavoriteItem", + "FileuploadItem", + "FlowItem", + "FlowRunItem", + "GroupItem", + "IntervalItem", + "JobItem", + "DailyInterval", + "WeeklyInterval", + "MonthlyInterval", + "HourlyInterval", + "BackgroundJobItem", + "MetricItem", + "PaginationItem", + "Permission", + "PermissionsRule", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "ServerInfoItem", + "SiteItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "PersonalAccessTokenAuth", + "JWTAuth", + "Resource", + "TableauItem", + "plural_type", + "TagItem", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WorkbookItem", +] diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 5abe19446..f5cd1d236 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,16 +1,91 @@ # These two imports must come first -from .request_factory import RequestFactory -from .request_options import ( +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, ) +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.sort import Sort +from tableauserverclient.server.server import Server +from tableauserverclient.server.pager import Pager +from tableauserverclient.server.endpoint.exceptions import NotSignedInError -from .filter import Filter -from .sort import Sort -from .endpoint import * -from .server import Server -from .pager import Pager -from .endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint import ( + Auth, + CustomViews, + DataAccelerationReport, + DataAlerts, + Databases, + Datasources, + QuerysetEndpoint, + MissingRequiredFieldError, + Endpoint, + Favorites, + Fileuploads, + FlowRuns, + Flows, + FlowTasks, + Groups, + Jobs, + Metadata, + Metrics, + Projects, + Schedules, + ServerInfo, + ServerResponseError, + Sites, + Subscriptions, + Tables, + Tasks, + Users, + Views, + Webhooks, + Workbooks, +) + +__all__ = [ + "RequestFactory", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "Filter", + "Sort", + "Server", + "Pager", + "NotSignedInError", + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index b2f291369..024350aaa 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,28 +1,61 @@ -from .auth_endpoint import Auth -from .custom_views_endpoint import CustomViews -from .data_acceleration_report_endpoint import DataAccelerationReport -from .data_alert_endpoint import DataAlerts -from .databases_endpoint import Databases -from .datasources_endpoint import Datasources -from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError -from .favorites_endpoint import Favorites -from .fileuploads_endpoint import Fileuploads -from .flow_runs_endpoint import FlowRuns -from .flows_endpoint import Flows -from .flow_task_endpoint import FlowTasks -from .groups_endpoint import Groups -from .jobs_endpoint import Jobs -from .metadata_endpoint import Metadata -from .metrics_endpoint import Metrics -from .projects_endpoint import Projects -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 tableauserverclient.server.endpoint.auth_endpoint import Auth +from tableauserverclient.server.endpoint.custom_views_endpoint import CustomViews +from tableauserverclient.server.endpoint.data_acceleration_report_endpoint import DataAccelerationReport +from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts +from tableauserverclient.server.endpoint.databases_endpoint import Databases +from tableauserverclient.server.endpoint.datasources_endpoint import Datasources +from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint +from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.favorites_endpoint import Favorites +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads +from tableauserverclient.server.endpoint.flow_runs_endpoint import FlowRuns +from tableauserverclient.server.endpoint.flows_endpoint import Flows +from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks +from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.metadata_endpoint import Metadata +from tableauserverclient.server.endpoint.metrics_endpoint import Metrics +from tableauserverclient.server.endpoint.projects_endpoint import Projects +from tableauserverclient.server.endpoint.schedules_endpoint import Schedules +from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo +from tableauserverclient.server.endpoint.sites_endpoint import Sites +from tableauserverclient.server.endpoint.subscriptions_endpoint import Subscriptions +from tableauserverclient.server.endpoint.tables_endpoint import Tables +from tableauserverclient.server.endpoint.tasks_endpoint import Tasks +from tableauserverclient.server.endpoint.users_endpoint import Users +from tableauserverclient.server.endpoint.views_endpoint import Views +from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks +from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks + +__all__ = [ + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 3a6831458..10b1a53ad 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -5,9 +5,7 @@ from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version - -from . import CustomViews -from .endpoint import ( +from tableauserverclient.server.endpoint import ( Sites, Views, Users, @@ -34,13 +32,14 @@ FlowRuns, Metrics, Endpoint, + CustomViews, ) -from .exceptions import ( +from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .endpoint.exceptions import NotSignedInError -from ..namespace import Namespace +from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.namespace import Namespace _PRODUCT_TO_REST_VERSION = { From 6e68d8b20f842daabf6b3b0e998141c8d8daace9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 5 Jun 2024 20:43:20 -0500 Subject: [PATCH 419/567] chore: add typing to Pager --- tableauserverclient/server/pager.py | 93 +++++++++++++++-------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 3220f5372..21b5a4ed0 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,9 +1,28 @@ +import copy from functools import partial +from typing import Generic, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable -from . import RequestOptions +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions -class Pager(object): +T = TypeVar("T") +ReturnType = Tuple[List[T], PaginationItem] + + +@runtime_checkable +class Endpoint(Protocol): + def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +@runtime_checkable +class CallableEndpoint(Protocol): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +class Pager(Generic[T]): """ Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server. Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models @@ -12,12 +31,17 @@ class Pager(object): Will loop over anything that returns (List[ModelItem], PaginationItem). """ - def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, "get"): + def __init__( + self, + endpoint: Union[CallableEndpoint, Endpoint], + request_opts: Optional[RequestOptions] = None, + **kwargs, + ) -> None: + if isinstance(endpoint, Endpoint): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) self._endpoint = endpoint - elif callable(endpoint): + elif isinstance(endpoint, CallableEndpoint): # but if they pass a callable then use that instead (used internally) endpoint = partial(endpoint, **kwargs) self._endpoint = endpoint @@ -25,47 +49,24 @@ def __init__(self, endpoint, request_opts=None, **kwargs): # Didn't get something we can page over raise ValueError("Pager needs a server endpoint to page through.") - self._options = request_opts + self._options = request_opts or RequestOptions() - # If we have options we could be starting on any page, backfill the count - if self._options: - self._count = (self._options.pagenumber - 1) * self._options.pagesize - else: - self._count = 0 - self._options = RequestOptions() - - def __iter__(self): - # Fetch the first page - current_item_list, last_pagination_item = self._endpoint(self._options) - - if last_pagination_item.total_available is None: - # This endpoint does not support pagination, drain the list and return - while current_item_list: - yield current_item_list.pop(0) - - return - - # Get the rest on demand as a generator - while self._count < last_pagination_item.total_available: - if ( - len(current_item_list) == 0 - and (last_pagination_item.page_number * last_pagination_item.page_size) - < last_pagination_item.total_available - ): - current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) - - try: - yield current_item_list.pop(0) - self._count += 1 - - except IndexError: - # The total count on Server changed while fetching exit gracefully + def __iter__(self) -> Iterator[T]: + options = copy.deepcopy(self._options) + while True: + # Fetch the first page + current_item_list, pagination_item = self._endpoint(options) + + if pagination_item.total_available is None: + # This endpoint does not support pagination, drain the list and return + yield from current_item_list + return + yield from current_item_list + + if pagination_item.page_size * pagination_item.page_number >= pagination_item.total_available: + # Last page, exit return - def _load_next_page(self, last_pagination_item): - next_page = last_pagination_item.page_number + 1 - opts = RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size) - if self._options is not None: - opts.sort, opts.filter = self._options.sort, self._options.filter - current_item_list, last_pagination_item = self._endpoint(opts) - return current_item_list, last_pagination_item + # Update the options to fetch the next page + options.pagenumber = pagination_item.page_number + 1 + options.pagesize = pagination_item.page_size From c5d6abcaabf6a44a510f9aaab26715c915410ef6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 20:33:46 -0500 Subject: [PATCH 420/567] feat: add usage to views.get_by_id --- .../server/endpoint/views_endpoint.py | 4 +++- test/assets/view_get_id_usage.xml | 13 ++++++++++++ test/test_view.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/assets/view_get_id_usage.xml diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9c4b90657..c2075dbd2 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -50,12 +50,14 @@ def get( return all_view_items, pagination_item @api(version="3.1") - def get_by_id(self, view_id: str) -> ViewItem: + def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) logger.info("Querying single view (ID: {0})".format(view_id)) url = "{0}/{1}".format(self.baseurl, view_id) + if usage: + url += "?includeUsageStatistics=true" server_response = self.get_request(url) return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/test/assets/view_get_id_usage.xml b/test/assets/view_get_id_usage.xml new file mode 100644 index 000000000..a0cdd98db --- /dev/null +++ b/test/assets/view_get_id_usage.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/test_view.py b/test/test_view.py index 720a0ce64..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -13,6 +13,7 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") +GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") @@ -81,6 +82,25 @@ def test_get_by_id(self) -> None: self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) + def test_get_by_id_usage(self) -> None: + with open(GET_XML_ID_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5?includeUsageStatistics=true", text=response_xml) + view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True) + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) + self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + self.assertEqual("story", view.sheet_type) + self.assertEqual(7, view.total_views) + def test_get_by_id_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) From ff7ab6514cab92cd973154c7497661ae3b5eec99 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 20:36:12 -0500 Subject: [PATCH 421/567] chore: make pager generic type more specific --- tableauserverclient/server/pager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 21b5a4ed0..fede56012 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,6 @@ import copy from functools import partial -from typing import Generic, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -22,7 +22,7 @@ def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnT ... -class Pager(Generic[T]): +class Pager(Iterable[T]): """ Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server. Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models From c7cec8592bafce238644926e9971034631882017 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 22:17:21 -0500 Subject: [PATCH 422/567] chore: type hint QuerySet and QuerySetEndpoint --- .../server/endpoint/custom_views_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 27 +++++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 2 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/query.py | 71 ++++++++++++------- 13 files changed, 78 insertions(+), 42 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 119580609..d1446b1fe 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -17,7 +17,7 @@ """ -class CustomViews(QuerysetEndpoint): +class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): super(CustomViews, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 28226d280..da2ee3def 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -54,7 +54,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint): +class Datasources(QuerysetEndpoint[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b7f57069..d9dac47b2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,9 +1,13 @@ from tableauserverclient import datetime_helpers as datetime +import abc from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union + +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions from .exceptions import ( ServerResponseError, @@ -300,25 +304,36 @@ def wrapper(self, *args, **kwargs): return _decorator -class QuerysetEndpoint(Endpoint): +T = TypeVar("T") + + +class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, **kwargs): + def all(self, *args, **kwargs) -> QuerySet[T]: + if args or kwargs: + raise ValueError(".all method takes no arguments.") queryset = QuerySet(self) return queryset @api(version="2.0") - def filter(self, *_, **kwargs) -> QuerySet: + def filter(self, *_, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) return queryset @api(version="2.0") - def order_by(self, *args, **kwargs): + def order_by(self, *args, **kwargs) -> QuerySet[T]: + if kwargs: + raise ValueError(".order_by does not accept keyword arguments.") queryset = QuerySet(self).order_by(*args) return queryset @api(version="2.0") - def paginate(self, **kwargs): + def paginate(self, **kwargs) -> QuerySet[T]: queryset = QuerySet(self).paginate(**kwargs) return queryset + + @abc.abstractmethod + def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 63b32e006..ea45ce802 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -13,7 +13,7 @@ from ..request_options import RequestOptions -class FlowRuns(QuerysetEndpoint): +class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: super(FlowRuns, self).__init__(parent_srv) return None diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 77b01c478..e392d807d 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -50,7 +50,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint): +class Flows(QuerysetEndpoint[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ab5f672d1..caa928f88 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -14,7 +14,7 @@ from ..request_options import RequestOptions -class Groups(QuerysetEndpoint): +class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index d0b865e21..74770e22b 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,7 +11,7 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint): +class Jobs(QuerysetEndpoint[JobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index a0e984475..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -18,7 +18,7 @@ from tableauserverclient.helpers.logging import logger -class Metrics(QuerysetEndpoint): +class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 99bb2e39b..7645e72eb 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -16,7 +16,7 @@ from tableauserverclient.helpers.logging import logger -class Projects(QuerysetEndpoint): +class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: super(Projects, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index e8c5cc962..a84ca7399 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -11,7 +11,7 @@ from tableauserverclient.helpers.logging import logger -class Users(QuerysetEndpoint): +class Users(QuerysetEndpoint[UserItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9c4b90657..87a77053f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -21,7 +21,7 @@ ) -class Views(QuerysetEndpoint): +class Views(QuerysetEndpoint[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bc535b2d6..5b4b29969 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -56,7 +56,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint): +class Workbooks(QuerysetEndpoint[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index c5613b2d6..d52332622 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,25 @@ -from typing import Tuple -from .filter import Filter -from .request_options import RequestOptions -from .sort import Sort +from collections.abc import Iterable, Sized +from itertools import count +from typing import Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.sort import Sort import math +from typing_extensions import Self + +if TYPE_CHECKING: + from tableauserverclient.server.endpoint import QuerysetEndpoint + +T = TypeVar("T") + + +class Slice(Protocol): + start: Optional[int] + step: Optional[int] + stop: Optional[int] + def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) @@ -16,28 +32,33 @@ def to_camel_case(word: str) -> str: """ -class QuerySet: - def __init__(self, model): +class QuerySet(Iterable[T], Sized): + def __init__(self, model: "QuerysetEndpoint[T]") -> None: self.model = model self.request_options = RequestOptions() - self._result_cache = None - self._pagination_item = None + self._result_cache: List[T] = [] + self._pagination_item = PaginationItem() - def __iter__(self): + def __iter__(self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties # the result cache. - self.request_options.pagenumber = 1 - self._result_cache = None - total = self.total_available - size = self.page_size - yield from self._result_cache - # Loop through the subsequent pages. - for page in range(1, math.ceil(total / size)): - self.request_options.pagenumber = page + 1 - self._result_cache = None + for page in count(1): + self.request_options.pagenumber = page self._fetch_all() yield from self._result_cache + # Set result_cache to empty so the fetch will populate + self._result_cache = [] + if (page * self.page_size) >= len(self): + return + + @overload + def __getitem__(self, k: Slice) -> List[T]: + ... + + @overload + def __getitem__(self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -78,7 +99,7 @@ def __getitem__(self, k): return self._result_cache[k % size] elif k in range(self.total_available): # Otherwise, check if k is even sensible to return - self._result_cache = None + self._result_cache = [] # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -86,11 +107,11 @@ def __getitem__(self, k): # If k is unreasonable, raise an IndexError. raise IndexError - def _fetch_all(self): + def _fetch_all(self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if self._result_cache is None: + if not self._result_cache: self._result_cache, self._pagination_item = self.model.get(self.request_options) def __len__(self) -> int: @@ -111,21 +132,21 @@ def page_size(self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self, *invalid, **kwargs): + def filter(self, *invalid, **kwargs) -> Self: if invalid: - raise RuntimeError(f"Only accepts keyword arguments.") + raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) return self - def order_by(self, *args): + def order_by(self, *args) -> Self: for arg in args: field_name, direction = self._parse_shorthand_sort(arg) self.request_options.sort.add(Sort(field_name, direction)) return self - def paginate(self, **kwargs): + def paginate(self, **kwargs) -> Self: if "page_number" in kwargs: self.request_options.pagenumber = kwargs["page_number"] if "page_size" in kwargs: From f7524e8dfc819fa645de27374834e6b8e3e1faa6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 22:24:38 -0500 Subject: [PATCH 423/567] fix: make 3.8 friendly --- tableauserverclient/server/query.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index d52332622..373987f31 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,6 @@ -from collections.abc import Iterable, Sized +from collections.abc import Sized from itertools import count -from typing import Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions From 3ae6de8472d8c41d44c02d663b6b5001e94238d3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 06:05:43 -0500 Subject: [PATCH 424/567] fix: ensure result_cache is empty before looping --- tableauserverclient/server/query.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 373987f31..ad9b6f291 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -41,7 +41,9 @@ def __init__(self, model: "QuerysetEndpoint[T]") -> None: def __iter__(self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties - # the result cache. + # the result cache. Ensure the result_cache is empty to not yield + # items from prior usage. + self._result_cache = [] for page in count(1): self.request_options.pagenumber = page From 35643e540932d25370248722d7c9a98e18ec7971 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 06:30:52 -0500 Subject: [PATCH 425/567] chore: add self type hints --- tableauserverclient/server/query.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index ad9b6f291..99e70894d 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -39,7 +39,7 @@ def __init__(self, model: "QuerysetEndpoint[T]") -> None: self._result_cache: List[T] = [] self._pagination_item = PaginationItem() - def __iter__(self) -> Iterator[T]: + def __iter__(self: Self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties # the result cache. Ensure the result_cache is empty to not yield # items from prior usage. @@ -55,11 +55,11 @@ def __iter__(self) -> Iterator[T]: return @overload - def __getitem__(self, k: Slice) -> List[T]: + def __getitem__(self: Self, k: Slice) -> List[T]: ... @overload - def __getitem__(self, k: int) -> T: + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): @@ -109,32 +109,32 @@ def __getitem__(self, k): # If k is unreasonable, raise an IndexError. raise IndexError - def _fetch_all(self) -> None: + def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ if not self._result_cache: self._result_cache, self._pagination_item = self.model.get(self.request_options) - def __len__(self) -> int: + def __len__(self: Self) -> int: return self.total_available @property - def total_available(self) -> int: + def total_available(self: Self) -> int: self._fetch_all() return self._pagination_item.total_available @property - def page_number(self) -> int: + def page_number(self: Self) -> int: self._fetch_all() return self._pagination_item.page_number @property - def page_size(self) -> int: + def page_size(self: Self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self, *invalid, **kwargs) -> Self: + def filter(self: Self, *invalid, **kwargs) -> Self: if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): @@ -142,20 +142,20 @@ def filter(self, *invalid, **kwargs) -> Self: self.request_options.filter.add(Filter(field_name, operator, value)) return self - def order_by(self, *args) -> Self: + def order_by(self: Self, *args) -> Self: for arg in args: field_name, direction = self._parse_shorthand_sort(arg) self.request_options.sort.add(Sort(field_name, direction)) return self - def paginate(self, **kwargs) -> Self: + def paginate(self: Self, **kwargs) -> Self: if "page_number" in kwargs: self.request_options.pagenumber = kwargs["page_number"] if "page_size" in kwargs: self.request_options.pagesize = kwargs["page_size"] return self - def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -169,7 +169,7 @@ def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self, key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(self: Self, key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc From 2e4e3c05fa1d709cfe5f5cb90751cedc8cd07bb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 22:23:39 +0000 Subject: [PATCH 426/567] Bump urllib3 from 2.0.7 to 2.2.2 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.7 to 2.2.2. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.0.7...2.2.2) --- updated-dependencies: - dependency-name: urllib3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fceb37237..ff76300a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.0.7', # latest as at 7/31/23 + 'urllib3==2.2.2', # latest as at 7/31/23 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" From de32333a989b76a30161a2a4bec5c1672efa3914 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:35:28 -0500 Subject: [PATCH 427/567] feat: add with_page_size method onto QuerySet --- tableauserverclient/server/query.py | 4 ++++ test/test_request_option.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 99e70894d..98eb88a07 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -155,6 +155,10 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def with_page_size(self: Self, value: int) -> Self: + self.request_options.pagesize = value + return self + def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: diff --git a/test/test_request_option.py b/test/test_request_option.py index 40dd3345a..a9d2941c0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -331,3 +331,15 @@ def test_filtering_parameters(self) -> None: self.assertIn("value2", query_params["name2$"]) self.assertIn("type", query_params) self.assertIn("tabloid", query_params["type"]) + + def test_queryset_pagesize(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get( + f"{self.baseurl}/views?pageSize={page_size}", + text=SLICING_QUERYSET_PAGE_1.read_text() + ) + _ = self.server.views.all().with_page_size(page_size) + + From c9b92ecaa4e9a2a6baa4cc5f6cfc83b4f9a45781 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:37:01 -0500 Subject: [PATCH 428/567] style: black --- test/test_request_option.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index a9d2941c0..9870695d9 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -336,10 +336,5 @@ def test_queryset_pagesize(self) -> None: for page_size in (1, 10, 100, 1000): with self.subTest(page_size): with requests_mock.mock() as m: - m.get( - f"{self.baseurl}/views?pageSize={page_size}", - text=SLICING_QUERYSET_PAGE_1.read_text() - ) + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) _ = self.server.views.all().with_page_size(page_size) - - From 7b0cd6aeade0799230c7b8d888dc5861d5e3daac Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:43:24 -0500 Subject: [PATCH 429/567] fix: ensure queryset iterator is called in test --- test/test_request_option.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9870695d9..5ade81ea1 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -337,4 +337,5 @@ def test_queryset_pagesize(self) -> None: with self.subTest(page_size): with requests_mock.mock() as m: m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - _ = self.server.views.all().with_page_size(page_size) + queryset = self.server.views.all().with_page_size(page_size) + _ = list(queryset) From 2def515bd92168a49eb3049024e0eb40f1735048 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:33:14 -0500 Subject: [PATCH 430/567] fix: change when result cache gets emptied There are many methods on QuerySet that implicitly call fetch_all. This moves emptying the result cache to immediately before the explicit call to fetch_call after the page number has been updated. This ensures that the correct latest page is fetched. --- tableauserverclient/server/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 98eb88a07..51c34d082 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -47,10 +47,10 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page + self._result_cache = [] self._fetch_all() yield from self._result_cache # Set result_cache to empty so the fetch will populate - self._result_cache = [] if (page * self.page_size) >= len(self): return From 55fa24cf84f9964bd6eaa44c2a86838afc2cd145 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Jun 2024 18:16:51 -0700 Subject: [PATCH 431/567] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ff76300a7..202aed968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.2.2', # latest as at 7/31/23 + 'urllib3==2.2.2', # dependabot 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" From a5c28dacc123a3b3c7970a8630b11f5d06ecb0ad Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:35:35 -0500 Subject: [PATCH 432/567] chore: type hint auth models --- tableauserverclient/models/tableau_auth.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 8cb2a8848..76f5c38e4 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,16 +1,18 @@ import abc +from typing import Optional class Credentials(abc.ABC): - def __init__(self, site_id=None, user_id_to_impersonate=None): + def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property @abc.abstractmethod - def credentials(self): - credentials = "Credentials can be username/password, Personal Access Token, or JWT" - +"This method returns values to set as an attribute on the credentials element of the request" + def credentials(self) -> dict[str, str]: + credentials = ("Credentials can be username/password, Personal Access Token, or JWT" + "This method returns values to set as an attribute on the credentials element of the request") + return {"key": "value"} @abc.abstractmethod def __repr__(self): @@ -28,7 +30,7 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username, password, site_id=None, user_id_to_impersonate=None): + def __init__(self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -36,7 +38,7 @@ def __init__(self, username, password, site_id=None, user_id_to_impersonate=None self.username = username @property - def credentials(self): + def credentials(self) -> dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -49,7 +51,7 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): + def __init__(self, token_name: str, personal_access_token: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) @@ -57,7 +59,7 @@ def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_i self.personal_access_token = personal_access_token @property - def credentials(self): + def credentials(self) -> dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -76,14 +78,14 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) self.jwt = jwt @property - def credentials(self): + def credentials(self) -> dict[str, str]: return {"jwt": self.jwt} def __repr__(self): From 22745a01b4324615f185e8ce6807ce70e0d05d19 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:40:46 -0500 Subject: [PATCH 433/567] fix: dict[type, type] was added in 3.9 --- tableauserverclient/models/tableau_auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 76f5c38e4..d011809e9 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Optional +from typing import Dict, Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: credentials = ("Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request") return {"key": "value"} @@ -38,7 +38,7 @@ def __init__(self, username: str, password: str, site_id: Optional[str] = None, self.username = username @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -59,7 +59,7 @@ def __init__(self, token_name: str, personal_access_token: str, site_id: Optiona self.personal_access_token = personal_access_token @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -85,7 +85,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"jwt": self.jwt} def __repr__(self): From 2adcaccb11dd9ef9897a826176b776f26be82461 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:47:17 -0500 Subject: [PATCH 434/567] style: black --- tableauserverclient/models/tableau_auth.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index d011809e9..10cf58723 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -10,8 +10,10 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod def credentials(self) -> Dict[str, str]: - credentials = ("Credentials can be username/password, Personal Access Token, or JWT" - "This method returns values to set as an attribute on the credentials element of the request") + credentials = ( + "Credentials can be username/password, Personal Access Token, or JWT" + "This method returns values to set as an attribute on the credentials element of the request" + ) return {"key": "value"} @abc.abstractmethod @@ -30,7 +32,9 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__( + self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None + ) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -51,7 +55,13 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name: str, personal_access_token: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__( + self, + token_name: str, + personal_access_token: str, + site_id: Optional[str] = None, + user_id_to_impersonate: Optional[str] = None, + ) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) From bae9dd0cd74b029adb09c7ffb32d3182c96d94f0 Mon Sep 17 00:00:00 2001 From: Patrick Franco Braz Date: Thu, 20 Jun 2024 11:34:31 -0300 Subject: [PATCH 435/567] fix(endpoint): pop from empty list --- tableauserverclient/server/endpoint/metadata_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 39146d062..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -42,9 +42,9 @@ def extract(obj, arr, key): def get_page_info(result): - next_page = extract_values(result, "hasNextPage").pop() - cursor = extract_values(result, "endCursor").pop() - return next_page, cursor + next_page = extract_values(result, "hasNextPage") + cursor = extract_values(result, "endCursor") + return next_page.pop() if next_page else None, cursor.pop() if cursor else None class Metadata(Endpoint): From 75e7aaa650d7b91a085a203cf265e866246c9f3d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 29 Jun 2024 13:22:21 -0500 Subject: [PATCH 436/567] chore: absolute imports for favorites --- tableauserverclient/models/favorites_item.py | 14 +++++++------- .../server/endpoint/favorites_endpoint.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 987623404..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,14 +1,14 @@ import logging from defusedxml.ElementTree import fromstring -from .tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .metric_item import MetricItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem from typing import Dict, List from tableauserverclient.helpers.logging import logger diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f82b1b3d5..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api +from tableauserverclient.server.endpoint.endpoint import Endpoint, api from requests import Response from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( From 3c91a2e4e3ca0dc1730cdc15a856e78f06c510b9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:06:30 -0500 Subject: [PATCH 437/567] feat: add support for changing project owner Tableau REST API docs recently show this being supported. Adding it to TSC. Closes #157 --- tableauserverclient/models/project_item.py | 3 ++- tableauserverclient/server/request_factory.py | 3 +++ test/assets/project_update.xml | 4 +++- test/test_project.py | 4 +++- test/test_project_model.py | 5 ----- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 4918f1a14..0188f46db 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -34,6 +34,7 @@ def __init__( self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples + self._owner_id: Optional[str] = None self._permissions = None self._default_workbook_permissions = None @@ -119,7 +120,7 @@ def owner_id(self) -> Optional[str]: @owner_id.setter def owner_id(self, value: str) -> None: - raise NotImplementedError("REST API does not currently support updating project owner.") + self._owner_id = value def is_default(self): return self.name.lower() == "default" diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 95460b54e..87438ecde 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -482,6 +482,9 @@ def update_req(self, project_item: "ProjectItem") -> bytes: project_element.attrib["contentPermissions"] = project_item.content_permissions if project_item.parent_id is not None: project_element.attrib["parentProjectId"] = project_item.parent_id + if (owner := project_item.owner_id) is not None: + owner_element = ET.SubElement(project_element, "owner") + owner_element.attrib["id"] = owner return ET.tostring(xml_request) def create_req(self, project_item: "ProjectItem") -> bytes: diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index eaa884627..f2485c898 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,6 @@ - + + + diff --git a/test/test_project.py b/test/test_project.py index 33d9c3865..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -79,6 +79,7 @@ def test_update(self) -> None: parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", ) single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project = self.server.projects.update(single_project) self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) @@ -86,6 +87,7 @@ def test_update(self) -> None: self.assertEqual("Project created for testing", single_project.description) self.assertEqual("LockedToProject", single_project.content_permissions) self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", single_project.parent_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_project.owner_id) def test_content_permission_locked_to_project_without_nested(self) -> None: with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: @@ -185,7 +187,7 @@ def test_populate_workbooks(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml ) single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) diff --git a/test/test_project_model.py b/test/test_project_model.py index 6ddaf8607..ecfe1bd14 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -19,8 +19,3 @@ def test_parent_id(self): project = TSC.ProjectItem("proj") project.parent_id = "foo" self.assertEqual(project.parent_id, "foo") - - def test_owner_id(self): - project = TSC.ProjectItem("proj") - with self.assertRaises(NotImplementedError): - project.owner_id = "new_owner" From b031d019b1ab52ea77b444c3fa4552dde0f0f423 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:11:05 -0500 Subject: [PATCH 438/567] chore: absolute imports --- tableauserverclient/models/project_item.py | 4 ++-- .../server/endpoint/projects_endpoint.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 0188f46db..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -4,8 +4,8 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty class ProjectItem(object): diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f25c91387..259f53b14 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,17 +1,17 @@ import logging -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions + from tableauserverclient.server.server import Server + from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.helpers.logging import logger From 9cd86ce03a946eed59ce4e336dcfd16ba6248c3b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:04:50 -0500 Subject: [PATCH 439/567] chore: ignore known internal warnings on tests --- test/test_site.py | 4 ++++ test/test_workbook.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/test/test_site.py b/test/test_site.py index b8469e56c..96b75f9ff 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,6 +1,7 @@ import os.path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -109,6 +110,8 @@ def test_get_by_name(self) -> None: def test_get_by_name_missing_name(self) -> None: self.assertRaises(ValueError, self.server.sites.get_by_name, "") + @pytest.mark.filterwarnings("ignore:Tiered license level is set") + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -206,6 +209,7 @@ def test_replace_license_tiers_with_user_quota(self) -> None: self.assertEqual(1, test_site.user_quota) self.assertIsNone(test_site.tier_explorer_capacity) + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_create(self) -> None: with open(CREATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index 595373e6e..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -7,6 +7,8 @@ from io import BytesIO from pathlib import Path +import pytest + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule @@ -622,6 +624,7 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) + @pytest.mark.filterwarnings("ignore:'as_job' not available") def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: response_xml = f.read().decode("utf-8") From 776c0099a7d078828ec1fe635c4d932f0105c2c0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 29 Jun 2024 13:25:50 -0500 Subject: [PATCH 440/567] chore: absolute imports for datasource --- tableauserverclient/models/datasource_item.py | 12 ++++++------ .../server/endpoint/datasources_endpoint.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index fb2db6663..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,16 +6,16 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .property_decorators import ( +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, property_is_enum, ) -from .revision_item import RevisionItem -from .tag_item import TagItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.tag_item import TagItem class DatasourceItem(object): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6233e3142..316f078a2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -15,11 +15,11 @@ from tableauserverclient.models import PermissionsRule from .schedules_endpoint import AddResponse -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( From ea53460af11414964b04de74e300bc55f44c408f Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 27 Jun 2024 22:23:06 -0500 Subject: [PATCH 441/567] chore: make auth endpoint imports absolute --- tableauserverclient/server/endpoint/auth_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 0b6bac0c9..468d469a7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -4,9 +4,9 @@ from defusedxml.ElementTree import fromstring -from .endpoint import Endpoint, api -from .exceptions import ServerResponseError -from ..request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.helpers.logging import logger From 3a3f15624c60f3cc19d96f71497b55885517a463 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:48:14 -0500 Subject: [PATCH 442/567] feat: enable bulk add and remove users --- .../server/endpoint/groups_endpoint.py | 55 ++++++++++++++----- tableauserverclient/server/request_factory.py | 24 +++++++- test/assets/group_add_users.xml | 8 +++ test/test_group.py | 49 +++++++++++++++++ 4 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 test/assets/group_add_users.xml diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2ee9fe0ab..8c1fe02a7 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,17 +1,17 @@ import logging -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: - from ..request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions class Groups(QuerysetEndpoint[GroupItem]): @@ -19,9 +19,9 @@ class Groups(QuerysetEndpoint[GroupItem]): def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) - # Gets all groups @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,9 +29,9 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Grou all_group_items = GroupItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item - # Gets all users in a given group @api(version="2.0") - def populate_users(self, group_item, req_options: Optional["RequestOptions"] = None) -> None: + def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: + """Gets all users in a given group""" if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -47,7 +47,7 @@ def user_pager(): group_item._set_users(user_pager) def _get_users_for_group( - self, group_item, req_options: Optional["RequestOptions"] = None + self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None ) -> Tuple[List[UserItem], PaginationItem]: url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) @@ -56,9 +56,9 @@ def _get_users_for_group( logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item - # Deletes 1 group by id @api(version="2.0") def delete(self, group_id: str) -> None: + """Deletes 1 group by id""" if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -87,17 +87,17 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: + """Create a 'local' Tableau group""" url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a group based on Active Directory @api(version="2.0") def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: + """Create a group based on Active Directory""" asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -107,9 +107,9 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: + """Removes 1 user from 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -120,9 +120,22 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: self.delete_request(url) logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) - # Adds 1 user to 1 group + @api(version="3.21") + def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: + """Removes multiple users from 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users/remove" + add_req = RequestFactory.Group.remove_users_req(users) + _ = self.put_request(url, add_req) + logger.info("Removed users to group (ID: {0})".format(group_item.id)) + return None + @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: + """Adds 1 user to 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -135,3 +148,17 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user + + @api(version="3.21") + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + """Adds multiple users to 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users" + add_req = RequestFactory.Group.add_users_req(users) + server_response = self.post_request(url, add_req) + users = UserItem.from_response(server_response.content, self.parent_srv.namespace) + logger.info("Added users to group (ID: {0})".format(group_item.id)) + return users diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecde..7bf2118fd 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -387,6 +387,28 @@ def add_user_req(self, user_id: str) -> bytes: user_element.attrib["id"] = user_id return ET.tostring(xml_request) + @_tsrequest_wrapped + def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def remove_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + def create_local_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") diff --git a/test/assets/group_add_users.xml b/test/assets/group_add_users.xml new file mode 100644 index 000000000..23fd7bd9f --- /dev/null +++ b/test/assets/group_add_users.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/test_group.py b/test/test_group.py index 1edc50555..fc9c75a6d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +ADD_USERS = TEST_ASSET_DIR / "group_add_users.xml" ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") @@ -123,6 +124,54 @@ def test_add_user(self) -> None: self.assertEqual("testuser", user.name) self.assertEqual("ServerAdministrator", user.site_role) + def test_add_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{group.id}/users", text=ADD_USERS.read_text()) + resp_users = self.server.groups.add_users(group, users) + + for user, resp_user in zip(users, resp_users): + with self.subTest(user=user, resp_user=resp_user): + assert user.id == resp_user.id + assert user.name == resp_user.name + assert user.site_role == resp_user.site_role + + def test_remove_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}/users/remove") + self.server.groups.remove_users(group, users) + def test_add_user_before_populating(self) -> None: with open(GET_XML, "rb") as f: get_xml_response = f.read().decode("utf-8") From 8f0609d1a2fa06b6fb29ad0fce97755943958ba0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:33:16 -0500 Subject: [PATCH 443/567] feat: parse linked task xml --- .../models/linked_tasks_item.py | 74 +++++++++++++++++++ .../server/endpoint/linked_tasks_endpoint.py | 17 +++++ test/assets/linked_tasks_get.xml | 33 +++++++++ test/test_linked_tasks.py | 62 ++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 tableauserverclient/models/linked_tasks_item.py create mode 100644 tableauserverclient/server/endpoint/linked_tasks_endpoint.py create mode 100644 test/assets/linked_tasks_get.xml create mode 100644 test/test_linked_tasks.py diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py new file mode 100644 index 000000000..995782218 --- /dev/null +++ b/tableauserverclient/models/linked_tasks_item.py @@ -0,0 +1,74 @@ +from typing import List, Optional +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.task_item import TaskItem + +class LinkedTaskItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.num_steps: Optional[int] = None + self.schedule: Optional[ScheduleItem] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + parsed_response = fromstring(resp) + return [cls._parse_element(x, namespace) for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": + task = cls() + task.id = xml.get("id") + task.num_steps = int(xml.get("numSteps")) + task.schedule = ScheduleItem.from_element(xml, namespace)[0] + return task + +class LinkedTaskStepItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.step_number: Optional[int] = None + self.stop_downstream_on_failure: Optional[bool] = None + self.task_details: List[LinkedTaskFlowRunItem] = [] + + @classmethod + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": + step = cls() + step.id = xml.get("id") + step.step_number = int(xml.get("stepNumber")) + step.stop_downstream_on_failure = string_to_bool(xml.get("stopDownstreamTasksOnFailure")) + step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) + return step + +class LinkedTaskFlowRunItem: + def __init__(self) -> None: + self.flow_run_id: Optional[str] = None + self.flow_run_priority: Optional[int] = None + self.flow_run_consecutive_failed_count: Optional[int] = None + self.flow_run_task_type: Optional[str] = None + self.flow_id: Optional[str] = None + self.flow_name: Optional[str] = None + + @classmethod + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + all_tasks = [] + for flow_run in xml.findall(".//t:flowRun[@id]", namespace): + task = cls() + task.flow_run_id = flow_run.get("id") + task.flow_run_priority = int(flow_run.get("priority")) + task.flow_run_consecutive_failed_count = int(flow_run.get("consecutiveFailedCount")) + task.flow_run_task_type = flow_run.get("type") + flow = flow_run.find(".//t:flow[@id]", namespace) + task.flow_id = flow.get("id") + task.flow_name = flow.get("name") + all_tasks.append(task) + + return all_tasks + + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py new file mode 100644 index 000000000..657592d35 --- /dev/null +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -0,0 +1,17 @@ +from typing import Optional +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_options import RequestOptions + +class LinkedTasks(QuerysetEndpoint): + def __init__(self, parent_srv): + super().__init__(parent_srv) + self._parent_srv = parent_srv + + @property + def baseurl(self): + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" + + @api(version="3.15") + def get(self, req_options: Optional[RequestOptions] = None): + ... + diff --git a/test/assets/linked_tasks_get.xml b/test/assets/linked_tasks_get.xml new file mode 100644 index 000000000..23b7bbbbc --- /dev/null +++ b/test/assets/linked_tasks_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py new file mode 100644 index 000000000..e9973906b --- /dev/null +++ b/test/test_linked_tasks.py @@ -0,0 +1,62 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import pytest + +import tableauserverclient as TSC +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem + +asset_dir = (Path(__file__).parent / "assets").resolve() + +GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" + +class TestLinkedTasks(unittest.TestCase): + + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + # self.baseurl = self.server.linked_tasks.baseurl + + def test_parse_linked_task_flow_run(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) + self.assertEqual(1, len(task_runs)) + task = task_runs[0] + self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") + self.assertEqual(task.flow_run_priority, 1) + self.assertEqual(task.flow_run_consecutive_failed_count, 3) + self.assertEqual(task.flow_run_task_type, "runFlow") + self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") + self.assertEqual(task.flow_name, "flow-name") + + + def test_parse_linked_task_step(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) + self.assertEqual(1, len(steps)) + step = steps[0] + self.assertEqual(step.id, "f554a4df-bb6f-4294-94ee-9a709ef9bda0") + self.assertTrue(step.stop_downstream_on_failure) + self.assertEqual(step.step_number, 1) + self.assertEqual(1, len(step.task_details)) + task = step.task_details[0] + self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") + self.assertEqual(task.flow_run_priority, 1) + self.assertEqual(task.flow_run_consecutive_failed_count, 3) + self.assertEqual(task.flow_run_task_type, "runFlow") + self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") + self.assertEqual(task.flow_name, "flow-name") + + def test_parse_linked_task(self): + tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) + self.assertEqual(1, len(tasks)) + task = tasks[0] + self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") + self.assertEqual(task.num_steps, 1) + self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + From 92c832051be0ccca9d53aff57dc6b4c63aca5819 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:52:50 -0500 Subject: [PATCH 444/567] feat: get linked tasks --- tableauserverclient/__init__.py | 7 +++++++ tableauserverclient/models/__init__.py | 8 ++++++++ .../models/linked_tasks_item.py | 1 - .../server/endpoint/__init__.py | 2 ++ .../server/endpoint/linked_tasks_endpoint.py | 19 ++++++++++++++----- tableauserverclient/server/server.py | 2 ++ test/test_linked_tasks.py | 16 +++++++++++++++- 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d810..65439690f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -21,6 +21,9 @@ IntervalItem, JobItem, JWTAuth, + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, MetricItem, MonthlyInterval, PaginationItem, @@ -116,4 +119,8 @@ "Pager", "Server", "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", + ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c3..fa4153154 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -22,6 +22,11 @@ HourlyInterval, ) from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.linked_tasks_item import ( + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, +) from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -91,4 +96,7 @@ "ViewItem", "WebhookItem", "WorkbookItem", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", ] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 995782218..1367cb158 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -2,7 +2,6 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.models.schedule_item import ScheduleItem -from tableauserverclient.models.task_item import TaskItem class LinkedTaskItem: def __init__(self) -> None: diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aaa..fb22302f2 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -13,6 +13,7 @@ from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks from tableauserverclient.server.endpoint.groups_endpoint import Groups from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.linked_tasks_endpoint import LinkedTasks from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics from tableauserverclient.server.endpoint.projects_endpoint import Projects @@ -44,6 +45,7 @@ "FlowTasks", "Groups", "Jobs", + "LinkedTasks", "Metadata", "Metrics", "Projects", diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 657592d35..df731a6b4 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,17 +1,26 @@ -from typing import Optional +from typing import List, Optional, Tuple + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem +from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.request_options import RequestOptions -class LinkedTasks(QuerysetEndpoint): +class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): def __init__(self, parent_srv): super().__init__(parent_srv) self._parent_srv = parent_srv @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional[RequestOptions] = None): - ... + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + logger.info("Querying all linked tasks on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items, pagination_item diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53ad..c2eaabb85 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + LinkedTasks, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +100,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.linked_tasks = LinkedTasks(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index e9973906b..50999abe7 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -3,6 +3,7 @@ from defusedxml.ElementTree import fromstring import pytest +import requests_mock import tableauserverclient as TSC from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem @@ -15,12 +16,13 @@ class TestLinkedTasks(unittest.TestCase): def setUp(self) -> None: self.server = TSC.Server("http://test", False) + self.server.version = "3.15" # Fake signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - # self.baseurl = self.server.linked_tasks.baseurl + self.baseurl = self.server.linked_tasks.baseurl def test_parse_linked_task_flow_run(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) @@ -60,3 +62,15 @@ def test_parse_linked_task(self): self.assertEqual(task.num_steps, 1) self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + def test_get_linked_tasks(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) + tasks, pagination_item = self.server.linked_tasks.get() + + self.assertEqual(1, len(tasks)) + task = tasks[0] + self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") + self.assertEqual(task.num_steps, 1) + self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + + From 605625541edcfb75c7c02b8073f2544dbe6a87ea Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jul 2024 22:53:39 -0500 Subject: [PATCH 445/567] style: black --- tableauserverclient/__init__.py | 1 - tableauserverclient/models/linked_tasks_item.py | 11 ++++++++--- .../server/endpoint/linked_tasks_endpoint.py | 2 +- test/test_linked_tasks.py | 5 +---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 65439690f..29d462f12 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -122,5 +122,4 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", - ] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 1367cb158..59dc65213 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -3,6 +3,7 @@ from tableauserverclient.models.schedule_item import ScheduleItem + class LinkedTaskItem: def __init__(self) -> None: self.id: Optional[str] = None @@ -12,7 +13,10 @@ def __init__(self) -> None: @classmethod def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: parsed_response = fromstring(resp) - return [cls._parse_element(x, namespace) for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace)] + return [ + cls._parse_element(x, namespace) + for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace) + ] @classmethod def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": @@ -22,9 +26,10 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": task.schedule = ScheduleItem.from_element(xml, namespace)[0] return task + class LinkedTaskStepItem: def __init__(self) -> None: - self.id: Optional[str] = None + self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None self.task_details: List[LinkedTaskFlowRunItem] = [] @@ -42,6 +47,7 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) return step + class LinkedTaskFlowRunItem: def __init__(self) -> None: self.flow_run_id: Optional[str] = None @@ -68,6 +74,5 @@ def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: return all_tasks - def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index df731a6b4..ffd527344 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -6,6 +6,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.request_options import RequestOptions + class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): def __init__(self, parent_srv): super().__init__(parent_srv) @@ -23,4 +24,3 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Link pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item - diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 50999abe7..916c86f27 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -12,8 +12,8 @@ GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" + class TestLinkedTasks(unittest.TestCase): - def setUp(self) -> None: self.server = TSC.Server("http://test", False) self.server.version = "3.15" @@ -36,7 +36,6 @@ def test_parse_linked_task_flow_run(self): self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") self.assertEqual(task.flow_name, "flow-name") - def test_parse_linked_task_step(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) @@ -72,5 +71,3 @@ def test_get_linked_tasks(self): self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") self.assertEqual(task.num_steps, 1) self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") - - From 643d7657fa7f5f92959b8742778218ea6f05cdd1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 05:33:27 -0500 Subject: [PATCH 446/567] chore: switch to pytest style asserts --- test/test_linked_tasks.py | 54 ++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 916c86f27..16f2b0306 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -27,47 +27,49 @@ def setUp(self) -> None: def test_parse_linked_task_flow_run(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) - self.assertEqual(1, len(task_runs)) + assert 1 == len(task_runs) task = task_runs[0] - self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") - self.assertEqual(task.flow_run_priority, 1) - self.assertEqual(task.flow_run_consecutive_failed_count, 3) - self.assertEqual(task.flow_run_task_type, "runFlow") - self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") - self.assertEqual(task.flow_name, "flow-name") + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" def test_parse_linked_task_step(self): xml = fromstring(GET_LINKED_TASKS.read_bytes()) steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) - self.assertEqual(1, len(steps)) + assert 1 == len(steps) step = steps[0] - self.assertEqual(step.id, "f554a4df-bb6f-4294-94ee-9a709ef9bda0") - self.assertTrue(step.stop_downstream_on_failure) - self.assertEqual(step.step_number, 1) - self.assertEqual(1, len(step.task_details)) + assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" + assert step.stop_downstream_on_failure + assert step.step_number == 1 + assert 1 == len(step.task_details) task = step.task_details[0] - self.assertEqual(task.flow_run_id, "e3d1fc25-5644-4e32-af35-58dcbd1dbd73") - self.assertEqual(task.flow_run_priority, 1) - self.assertEqual(task.flow_run_consecutive_failed_count, 3) - self.assertEqual(task.flow_run_task_type, "runFlow") - self.assertEqual(task.flow_id, "ab1231eb-b8ca-461e-a131-83f3c2b6a673") - self.assertEqual(task.flow_name, "flow-name") + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" def test_parse_linked_task(self): tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) - self.assertEqual(1, len(tasks)) + assert 1 == len(tasks) task = tasks[0] - self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") - self.assertEqual(task.num_steps, 1) - self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" def test_get_linked_tasks(self): with requests_mock.mock() as m: m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) tasks, pagination_item = self.server.linked_tasks.get() - self.assertEqual(1, len(tasks)) + assert 1 == len(tasks) task = tasks[0] - self.assertEqual(task.id, "1b8211dc-51a8-45ce-a831-b5921708e03e") - self.assertEqual(task.num_steps, 1) - self.assertEqual(task.schedule.id, "be077332-d01d-481b-b2f3-917e463d4dca") + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" From ddd1a71f99b1c10812fa074247e966a40234fd40 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 05:41:27 -0500 Subject: [PATCH 447/567] feat: linked task get_by_id --- .../server/endpoint/linked_tasks_endpoint.py | 11 +++++++- test/test_linked_tasks.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ffd527344..fd05b525d 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem @@ -24,3 +24,12 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Link pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item + + @api(version="3.15") + def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info("Querying all linked tasks on site") + url = f"{self.baseurl}/{task_id}" + server_response = self.get_request(url) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items[0] diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 16f2b0306..431045f76 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -73,3 +73,29 @@ def test_get_linked_tasks(self): assert task.num_steps == 1 assert task.schedule is not None assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(id_) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(in_task) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" From f8843d496e89d34e7c334ccfe89d760b2689c202 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jul 2024 06:00:04 -0500 Subject: [PATCH 448/567] feat: run linked task now --- .../models/linked_tasks_item.py | 24 ++++++++++++++++ .../server/endpoint/linked_tasks_endpoint.py | 12 +++++++- test/assets/linked_tasks_run_now.xml | 7 +++++ test/test_linked_tasks.py | 28 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/assets/linked_tasks_run_now.xml diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 59dc65213..ae9b60425 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,6 +1,9 @@ +import datetime as dt from typing import List, Optional + from defusedxml.ElementTree import fromstring +from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.schedule_item import ScheduleItem @@ -74,5 +77,26 @@ def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: return all_tasks +class LinkedTaskJobItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.linked_task_id: Optional[str] = None + self.status: Optional[str] = None + self.created_at: Optional[dt.datetime] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem": + parsed_response = fromstring(resp) + job = cls() + job_xml = parsed_response.find(".//t:linkedTaskJob[@id]", namespaces=namespace) + if job_xml is None: + raise ValueError("No linked task job found in response") + job.id = job_xml.get("id") + job.linked_task_id = job_xml.get("linkedTaskId") + job.status = job_xml.get("status") + job.created_at = parse_datetime(job_xml.get("createdAt")) + return job + + def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index fd05b525d..374130509 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,9 +1,10 @@ from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger -from tableauserverclient.models.linked_tasks_item import LinkedTaskItem +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.server.request_options import RequestOptions @@ -33,3 +34,12 @@ def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: server_response = self.get_request(url) all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items[0] + + @api(version="3.15") + def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info(f"Running linked task {task_id} now") + url = f"{self.baseurl}/{task_id}/runNow" + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + return LinkedTaskJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/assets/linked_tasks_run_now.xml b/test/assets/linked_tasks_run_now.xml new file mode 100644 index 000000000..63cef73b1 --- /dev/null +++ b/test/assets/linked_tasks_run_now.xml @@ -0,0 +1,7 @@ + + + + diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 431045f76..8ea5226d7 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -6,11 +6,13 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem asset_dir = (Path(__file__).parent / "assets").resolve() GET_LINKED_TASKS = asset_dir / "linked_tasks_get.xml" +RUN_LINKED_TASK_NOW = asset_dir / "linked_tasks_run_now.xml" class TestLinkedTasks(unittest.TestCase): @@ -99,3 +101,29 @@ def test_get_by_id_obj_linked_task(self): assert task.num_steps == 1 assert task.schedule is not None assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_run_now_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(id_) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ + + def test_run_now_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(in_task) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ From 062a55fbe52c415807d0605b6ec26772877bad88 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 07:08:05 -0500 Subject: [PATCH 449/567] chore: make imports for flows absolute --- tableauserverclient/models/flow_item.py | 12 ++++++------ .../server/endpoint/flow_runs_endpoint.py | 8 ++++---- .../server/endpoint/flows_endpoint.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d543ad8eb..edce2ec97 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -6,12 +6,12 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import Permission -from .property_decorators import property_not_nullable -from .tag_item import TagItem +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import Permission +from tableauserverclient.models.property_decorators import property_not_nullable +from tableauserverclient.models.tag_item import TagItem class FlowItem(object): diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index ea45ce802..04aefaeee 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,16 +1,16 @@ import logging from typing import List, Optional, Tuple, TYPE_CHECKING -from .endpoint import QuerysetEndpoint, api -from .exceptions import FlowRunFailedException, FlowRunCancelledException +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions + from tableauserverclient.server.server import Server + from tableauserverclient.server.request_options import RequestOptions class FlowRuns(QuerysetEndpoint[FlowRunItem]): diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2997e9456..858ff91ac 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -9,11 +9,11 @@ from tableauserverclient.helpers.headers import fix_filename -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( From 1745bf685d1ad8ce56be2a2b3534502ba6edc3be Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 25 Jun 2024 21:17:04 -0500 Subject: [PATCH 450/567] feat: add support for groupsets --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/groupset_item.py | 46 +++++++ .../server/endpoint/__init__.py | 2 + .../server/endpoint/groupsets_endpoint.py | 87 ++++++++++++ tableauserverclient/server/request_factory.py | 17 +++ tableauserverclient/server/server.py | 2 + test/assets/groupsets_create.xml | 4 + test/assets/groupsets_get.xml | 15 ++ test/assets/groupsets_get_by_id.xml | 9 ++ test/assets/groupsets_update.xml | 9 ++ test/test_groupsets.py | 130 ++++++++++++++++++ 12 files changed, 325 insertions(+) create mode 100644 tableauserverclient/models/groupset_item.py create mode 100644 tableauserverclient/server/endpoint/groupsets_endpoint.py create mode 100644 test/assets/groupsets_create.xml create mode 100644 test/assets/groupsets_get.xml create mode 100644 test/assets/groupsets_get_by_id.xml create mode 100644 test/assets/groupsets_update.xml create mode 100644 test/test_groupsets.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d810..22a0854ee 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -17,6 +17,7 @@ FlowRunItem, FileuploadItem, GroupItem, + GroupSetItem, HourlyInterval, IntervalItem, JobItem, @@ -79,6 +80,7 @@ "FlowRunItem", "FileuploadItem", "GroupItem", + "GroupSetItem", "HourlyInterval", "IntervalItem", "JobItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c3..de0c516b7 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -14,6 +14,7 @@ from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.flow_run_item import FlowRunItem from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, @@ -60,6 +61,7 @@ "FlowItem", "FlowRunItem", "GroupItem", + "GroupSetItem", "IntervalItem", "JobItem", "DailyInterval", diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py new file mode 100644 index 000000000..9df87ef3f --- /dev/null +++ b/tableauserverclient/models/groupset_item.py @@ -0,0 +1,46 @@ +from typing import Dict, List, Optional +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.group_item import GroupItem + + +class GroupSetItem: + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + self.id: Optional[str] = None + self.groups: List["GroupItem"] = [] + self.group_count: int = 0 + + def __str__(self) -> str: + name = self.name + id = self.id + return f"<{self.__class__.__qualname__}({name=}, {id=})>" + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + parsed_response = fromstring(response) + all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) + return [cls.from_xml(xml, ns) for xml in all_groupset_xml] + + @classmethod + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def get_group(group_xml: ET.Element) -> GroupItem: + group_item = GroupItem() + group_item._id = group_xml.get("id") + group_item.name = group_xml.get("name") + return group_item + + group_set_item = cls() + group_set_item.name = groupset_xml.get("name") + group_set_item.id = groupset_xml.get("id") + group_set_item.group_count = int(count) if (count := groupset_xml.get("groupCount")) else 0 + group_set_item.groups = [ + get_group(group_xml) for group_xml in groupset_xml.findall(".//t:group", namespaces=ns) + ] + + return group_set_item diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aaa..e6b50b27d 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -12,6 +12,7 @@ from tableauserverclient.server.endpoint.flows_endpoint import Flows from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.groupsets_endpoint import GroupSets from tableauserverclient.server.endpoint.jobs_endpoint import Jobs from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics @@ -43,6 +44,7 @@ "Flows", "FlowTasks", "Groups", + "GroupSets", "Jobs", "Metadata", "Metrics", diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py new file mode 100644 index 000000000..d24cab52c --- /dev/null +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -0,0 +1,87 @@ +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import api + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class GroupSets(QuerysetEndpoint[GroupSetItem]): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groupsets" + + @api(version="3.22") + def get( + self, + request_options: Optional[RequestOptions] = None, + result_level: Optional[Literal["members", "local"]] = None, + ) -> Tuple[List[GroupSetItem], PaginationItem]: + logger.info("Querying all group sets on site") + url = self.baseurl + if result_level: + url += f"?resultlevel={result_level}" + server_response = self.get_request(url, request_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items, pagination_item + + @api(version="3.22") + def get_by_id(self, groupset_id: str) -> GroupSetItem: + logger.info(f"Querying group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + server_response = self.get_request(url) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items[0] + + @api(version="3.22") + def create(self, groupset_item: GroupSetItem) -> GroupSetItem: + logger.info(f"Creating group set (name: {groupset_item.name})") + url = self.baseurl + request = RequestFactory.GroupSet.create_request(groupset_item) + server_response = self.post_request(url, request) + created_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return created_groupset[0] + + @api(version="3.22") + def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.put_request(url) + return None + + @api(version="3.22") + def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def delete(self, groupset: Union[GroupSetItem, str]) -> None: + groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset + logger.info(f"Deleting group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def update(self, groupset: GroupSetItem) -> GroupSetItem: + logger.info(f"Updating group set (ID: {groupset.id})") + url = f"{self.baseurl}/{groupset.id}" + request = RequestFactory.GroupSet.update_request(groupset) + server_response = self.put_request(url, request) + updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return updated_groupset[0] diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecde..d7f01d099 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1246,6 +1246,22 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element.attrib["name"] = custom_view_item.name +class GroupSetRequest: + @_tsrequest_wrapped + def create_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -1261,6 +1277,7 @@ class RequestFactory(object): Flow = FlowRequest() FlowTask = FlowTaskRequest() Group = GroupRequest() + GroupSet = GroupSetRequest() Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53ad..18d67fa07 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,7 @@ Metrics, Endpoint, CustomViews, + GroupSets, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +100,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.group_sets = GroupSets(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/groupsets_create.xml b/test/assets/groupsets_create.xml new file mode 100644 index 000000000..233b0f939 --- /dev/null +++ b/test/assets/groupsets_create.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/groupsets_get.xml b/test/assets/groupsets_get.xml new file mode 100644 index 000000000..ff3bec1fb --- /dev/null +++ b/test/assets/groupsets_get.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/assets/groupsets_get_by_id.xml b/test/assets/groupsets_get_by_id.xml new file mode 100644 index 000000000..558e4d870 --- /dev/null +++ b/test/assets/groupsets_get_by_id.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/groupsets_update.xml b/test/assets/groupsets_update.xml new file mode 100644 index 000000000..b64fa6ea1 --- /dev/null +++ b/test/assets/groupsets_update.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/test_groupsets.py b/test/test_groupsets.py new file mode 100644 index 000000000..d3c9085a4 --- /dev/null +++ b/test/test_groupsets.py @@ -0,0 +1,130 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import requests_mock + +import tableauserverclient as TSC + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" +GROUPSETS_GET = TEST_ASSET_DIR / "groupsets_get.xml" +GROUPSET_GET_BY_ID = TEST_ASSET_DIR / "groupsets_get_by_id.xml" +GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml" + + +class TestGroupSets(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.group_sets.baseurl + + def test_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=GROUPSETS_GET.read_text()) + groupsets, pagination_item = self.server.group_sets.get() + + assert len(groupsets) == 3 + assert pagination_item.total_available == 3 + assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupsets[0].name == "All Users" + assert groupsets[0].group_count == 1 + assert groupsets[0].groups[0].name == "group-one" + assert groupsets[0].groups[0].id == "gs-1" + + assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" + assert groupsets[1].name == "active-directory-group-import" + assert groupsets[1].group_count == 1 + assert groupsets[1].groups[0].name == "group-two" + assert groupsets[1].groups[0].id == "gs21" + + assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" + assert groupsets[2].name == "local-group-license-on-login" + assert groupsets[2].group_count == 1 + assert groupsets[2].groups[0].name == "group-three" + assert groupsets[2].groups[0].id == "gs-3" + + def test_get_by_id(self) -> None: + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) + groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_update(self) -> None: + id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + groupset = TSC.GroupSetItem("All Users") + groupset.id = id_ + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) + groupset = self.server.group_sets.update(groupset) + + assert groupset.id == id_ + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_create(self) -> None: + groupset = TSC.GroupSetItem("All Users") + with requests_mock.mock() as m: + m.post(self.baseurl, text=GROUPSET_CREATE.read_text()) + groupset = self.server.group_sets.create(groupset) + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 0 + assert len(groupset.groups) == 0 + + def test_add_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.add_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "PUT" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_remove_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.remove_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "DELETE" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" From 6351d36bc906403041ea046d23e29027e538a7e9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 28 Jun 2024 06:44:40 -0500 Subject: [PATCH 451/567] feat: add tag name to GroupSet --- tableauserverclient/models/groupset_item.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index 9df87ef3f..879e8b02d 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -7,6 +7,8 @@ class GroupSetItem: + tag_name: str = "groupSet" + def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None From dcf89aba85612a7e9a3e19ad7cc548b88caf43f5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 30 Jun 2024 07:28:24 -0500 Subject: [PATCH 452/567] feat: allow setting page_size in .all and .filter 1399 introduced a `with_page_size` method that allowed a user to specify the page_size of requests in the chains. It felt awkward in practice, so this moves it to be a keyword only argument of the `.all` and `.filter` methods for querysets. As 1399 has not yet been merged into master, this should be a non breaking change for consumers of TSC. --- .../server/endpoint/endpoint.py | 8 +++---- tableauserverclient/server/query.py | 19 +++++++-------- test/test_request_option.py | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index d9dac47b2..6b29e736a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -309,17 +309,17 @@ def wrapper(self, *args, **kwargs): class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, **kwargs) -> QuerySet[T]: + def all(self, *args, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: if args or kwargs: raise ValueError(".all method takes no arguments.") - queryset = QuerySet(self) + queryset = QuerySet(self, page_size=page_size) return queryset @api(version="2.0") - def filter(self, *_, **kwargs) -> QuerySet[T]: + def filter(self, *_, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") - queryset = QuerySet(self).filter(**kwargs) + queryset = QuerySet(self, page_size=page_size).filter(**kwargs) return queryset @api(version="2.0") diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 51c34d082..195139269 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -33,9 +33,9 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): - def __init__(self, model: "QuerysetEndpoint[T]") -> None: + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions() + self.request_options = RequestOptions(pagesize=page_size or 100) self._result_cache: List[T] = [] self._pagination_item = PaginationItem() @@ -134,12 +134,15 @@ def page_size(self: Self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self: Self, *invalid, **kwargs) -> Self: + def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) + + if page_size: + self.request_options.pagesize = page_size return self def order_by(self: Self, *args) -> Self: @@ -155,11 +158,8 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self - def with_page_size(self: Self, value: int) -> Self: - self.request_options.pagesize = value - return self - - def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -173,7 +173,8 @@ def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self: Self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/test/test_request_option.py b/test/test_request_option.py index 5ade81ea1..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -332,10 +332,29 @@ def test_filtering_parameters(self) -> None: self.assertIn("type", query_params) self.assertIn("tabloid", query_params["type"]) - def test_queryset_pagesize(self) -> None: + def test_queryset_endpoint_pagesize_all(self) -> None: for page_size in (1, 10, 100, 1000): with self.subTest(page_size): with requests_mock.mock() as m: m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - queryset = self.server.views.all().with_page_size(page_size) + queryset = self.server.views.all(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_endpoint_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.all().filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size _ = list(queryset) From 91cdd5f7584ca1f2c3bda874d98842d09e4c023b Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 17:27:47 -0700 Subject: [PATCH 453/567] Update Contributors list Updating this list which had become quite old. Also including a simple script for updating the list periodically. TODO: Improve the script to include the user's name as well. (Will require using a Github token to avoid being rate limited.) --- CONTRIBUTORS.md | 126 ++++++++++++++++++++++++++------------------- getcontributors.py | 9 ++++ 2 files changed, 82 insertions(+), 53 deletions(-) create mode 100644 getcontributors.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 89b8d213c..a69cfff21 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,59 +4,79 @@ The following people have contributed to this project to make it possible, and w ## Contributors +* [jacalata](https://github.com/jacalata) +* [jorwoods](https://github.com/jorwoods) +* [t8y8](https://github.com/t8y8) +* [bcantoni](https://github.com/bcantoni) +* [shinchris](https://github.com/shinchris) +* [vogelsgesang](https://github.com/vogelsgesang) +* [lbrendanl](https://github.com/lbrendanl) +* [LGraber](https://github.com/LGraber) +* [gaoang2148](https://github.com/gaoang2148) +* [benlower](https://github.com/benlower) +* [liu-rebecca](https://github.com/liu-rebecca) +* [guodah](https://github.com/guodah) +* [jdomingu](https://github.com/jdomingu) +* [kykrueger](https://github.com/kykrueger) +* [jz-huang](https://github.com/jz-huang) +* [opus-42](https://github.com/opus-42) +* [markm-io](https://github.com/markm-io) +* [graysonarts](https://github.com/graysonarts) +* [d45](https://github.com/d45) +* [preguraman](https://github.com/preguraman) +* [sotnich](https://github.com/sotnich) +* [mmuttreja-tableau](https://github.com/mmuttreja-tableau) +* [dependabot[bot]](https://github.com/apps/dependabot) +* [scuml](https://github.com/scuml) +* [ovinis](https://github.com/ovinis) +* [FFMMM](https://github.com/FFMMM) +* [martinbpeters](https://github.com/martinbpeters) +* [talvalin](https://github.com/talvalin) +* [dzucker-tab](https://github.com/dzucker-tab) +* [a-torres-2](https://github.com/a-torres-2) +* [nnevalainen](https://github.com/nnevalainen) +* [mbren](https://github.com/mbren) +* [wolkiewiczk](https://github.com/wolkiewiczk) +* [jacobj10](https://github.com/jacobj10) +* [hugoboos](https://github.com/hugoboos) +* [grbritz](https://github.com/grbritz) +* [fpagliar](https://github.com/fpagliar) +* [bskim45](https://github.com/bskim45) +* [baixin137](https://github.com/baixin137) +* [jessicachen79](https://github.com/jessicachen79) +* [gconklin](https://github.com/gconklin) * [geordielad](https://github.com/geordielad) -* [Hugo Stijns](https://github.com/hugoboos) -* [kovner](https://github.com/kovner) -* [Talvalin](https://github.com/Talvalin) -* [Chris Toomey](https://github.com/cmtoomey) -* [Vathsala Achar](https://github.com/VathsalaAchar) -* [Graeme Britz](https://github.com/grbritz) -* [Russ Goldin](https://github.com/tagyoureit) -* [William Lang](https://github.com/williamlang) -* [Jim Morris](https://github.com/jimbodriven) -* [BingoDinkus](https://github.com/BingoDinkus) -* [Sergey Sotnichenko](https://github.com/sotnich) -* [Bruce Zhang](https://github.com/baixin137) -* [Bumsoo Kim](https://github.com/bskim45) +* [fossabot](https://github.com/fossabot) * [daniel1608](https://github.com/daniel1608) -* [Joshua Jacob](https://github.com/jacobj10) -* [Francisco Pagliaricci](https://github.com/fpagliar) -* [Tomasz Machalski](https://github.com/toomyem) -* [Jared Dominguez](https://github.com/jdomingu) -* [Brendan Lee](https://github.com/lbrendanl) -* [Martin Dertz](https://github.com/martydertz) -* [Christian Oliff](https://github.com/coliff) -* [Albin Antony](https://github.com/user9747) -* [prae04](https://github.com/prae04) -* [Martin Peters](https://github.com/martinbpeters) -* [Sherman K](https://github.com/shrmnk) -* [Jorge Fonseca](https://github.com/JorgeFonseca) -* [Kacper Wolkiewicz](https://github.com/wolkiewiczk) -* [Dahai Guo](https://github.com/guodah) -* [Geraldine Zanolli](https://github.com/illonage) -* [Jordan Woods](https://github.com/jorwoods) -* [Reba Magier](https://github.com/rmagier1) -* [Stephen Mitchell](https://github.com/scuml) -* [absentmoose](https://github.com/absentmoose) -* [Paul Vickers](https://github.com/paulvic) -* [Madhura Selvarajan](https://github.com/maddy-at-leisure) -* [Niklas Nevalainen](https://github.com/nnevalainen) -* [Terrence Jones](https://github.com/tjones-commits) -* [John Vandenberg](https://github.com/jayvdb) -* [Lee Boynton](https://github.com/lboynton) * [annematronic](https://github.com/annematronic) - -## Core Team - -* [Chris Shin](https://github.com/shinchris) -* [Lee Graber](https://github.com/lgraber) -* [Tyler Doyle](https://github.com/t8y8) -* [Russell Hay](https://github.com/RussTheAerialist) -* [Ben Lower](https://github.com/benlower) -* [Ang Gao](https://github.com/gaoang2148) -* [Priya Reguraman](https://github.com/preguraman) -* [Jac Fitzgerald](https://github.com/jacalata) -* [Dan Zucker](https://github.com/dzucker-tab) -* [Brian Cantoni](https://github.com/bcantoni) -* [Ovini Nanayakkara](https://github.com/ovinis) -* [Manish Muttreja](https://github.com/mmuttreja-tableau) +* [rshide](https://github.com/rshide) +* [VathsalaAchar](https://github.com/VathsalaAchar) +* [TrimPeachu](https://github.com/TrimPeachu) +* [ajbosco](https://github.com/ajbosco) +* [jimbodriven](https://github.com/jimbodriven) +* [ltiffanydev](https://github.com/ltiffanydev) +* [martydertz](https://github.com/martydertz) +* [r-richmond](https://github.com/r-richmond) +* [sfarr15](https://github.com/sfarr15) +* [tagyoureit](https://github.com/tagyoureit) +* [tjones-commits](https://github.com/tjones-commits) +* [yoshichan5](https://github.com/yoshichan5) +* [wlodi83](https://github.com/wlodi83) +* [anipmehta](https://github.com/anipmehta) +* [cmtoomey](https://github.com/cmtoomey) +* [pes-magic](https://github.com/pes-magic) +* [illonage](https://github.com/illonage) +* [jayvdb](https://github.com/jayvdb) +* [jorgeFons](https://github.com/jorgeFons) +* [Kovner](https://github.com/Kovner) +* [LarsBreddemann](https://github.com/LarsBreddemann) +* [lboynton](https://github.com/lboynton) +* [maddy-at-leisure](https://github.com/maddy-at-leisure) +* [narcolino-tableau](https://github.com/narcolino-tableau) +* [PatrickfBraz](https://github.com/PatrickfBraz) +* [paulvic](https://github.com/paulvic) +* [shrmnk](https://github.com/shrmnk) +* [TableauKyle](https://github.com/TableauKyle) +* [bossenti](https://github.com/bossenti) +* [ma7tcsp](https://github.com/ma7tcsp) +* [toomyem](https://github.com/toomyem) diff --git a/getcontributors.py b/getcontributors.py new file mode 100644 index 000000000..54ca81cb2 --- /dev/null +++ b/getcontributors.py @@ -0,0 +1,9 @@ +import json +import requests + + +logins = json.loads( + requests.get("https://api.github.com/repos/tableau/server-client-python/contributors?per_page=200").text +) +for login in logins: + print(f"* [{login["login"]}]({login["html_url"]})") From 3a27d2b928587d93b7c809b284983f0e8a2efb57 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 17:29:33 -0700 Subject: [PATCH 454/567] Update contributing file to point to the newer Developer Guide instead --- contributing.md | 65 ++----------------------------------------------- 1 file changed, 2 insertions(+), 63 deletions(-) diff --git a/contributing.md b/contributing.md index 6404611a9..a0132919f 100644 --- a/contributing.md +++ b/contributing.md @@ -10,8 +10,6 @@ Contribution can include, but are not limited to, any of the following: * Fix an Issue/Bug * Add/Fix documentation -Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting a feature do not require the CLA. - ## Issues and Feature Requests To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. @@ -22,65 +20,6 @@ files to assist in the repro. **Be sure to scrub the files of any potentially s For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. -### Label usage on Issues - -The core team is responsible for assigning most labels to the issue. Labels -are used for prioritizing the core team's work, and use the following -definitions for labels. - -The following labels are only to be set or changed by the core team: - -* **bug** - A bug is an unintended behavior for existing functionality. It only relates to existing functionality and the behavior that is expected with that functionality. We do not use **bug** to indicate priority. -* **enhancement** - An enhancement is a new piece of functionality and is related to the fact that new code will need to be written in order to close this issue. We do not use **enhancement** to indicate priority. -* **CLARequired** - This label is used to indicate that the contribution will require that the CLA is signed before we can accept a PR. This label should not be used on Issues -* **CLANotRequired** - This label is used to indicate that the contribution does not require a CLA to be signed. This is used for minor fixes and usually around doc fixes or correcting strings. -* **help wanted** - This label on an issue indicates it's a good choice for external contributors to take on. It usually means it's an issue that can be tackled by first time contributors. - -The following labels can be used by the issue creator or anyone in the -community to help us prioritize enhancement and bug fixes that are -causing pain from our users. The short of it is, purple tags are ones that -anyone can add to an issue: - -* **Critical** - This means that you won't be able to use the library until the issues have been resolved. If an issue is already labeled as critical, but you want to show your support for it, add a +1 comment to the issue. This helps us know what issues are really impacting our users. -* **Nice To Have** - This means that the issue doesn't block your usage of the library, but would make your life easier. Like with critical, if the issue is already tagged with this, but you want to show your support, add a +1 comment to the issue. - -## Fixes, Implementations, and Documentation - -For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide). - -If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the -design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle -somewhere. - -## Getting Started - -```shell -python -m build -pytest -``` - -### To use your locally built version -```shell -pip install . -``` - -### Debugging Tools -See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) - - -### Before Committing - -Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. - -```shell -# this will run the formatter without making changes -black . --check - -# this will format the directory and code for you -black . +### Making Contributions -# this will run type checking -pip install mypy -mypy tableauserverclient test samples -``` +Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. From 4e578862c9590d18de1afe7aae27904309079479 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Sat, 27 Jul 2024 19:29:29 -0700 Subject: [PATCH 455/567] Add contributor pointers to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab6a66fae..51da7bda0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ To see sample code that works directly with the REST API (in Java, Python, or Po For more information on installing and using TSC, see the documentation: - +To contribute, see our [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide). A list of all our contributors to date is in [CONTRIBUTORS.md]. ## License [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) From b6c437289390c469aa08f20b0895dfa436d86139 Mon Sep 17 00:00:00 2001 From: joelclark Date: Wed, 31 Jul 2024 08:34:17 -0500 Subject: [PATCH 456/567] Add __str__ and __repr__ to DatabaseItem --- tableauserverclient/models/database_item.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 3d5a00a1a..dfc58e1bb 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -44,6 +44,12 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet + def __str__(self): + return "".format(self._id, self.name) + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def dqws(self): if self._data_quality_warnings is None: From ac43f1a98feeb5ac7468dfa3a3e27b3676b5d3f0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 22 Jul 2024 07:14:46 -0500 Subject: [PATCH 457/567] feat: add/remove tags endpoints for workbooks Closes #1421 --- .../server/endpoint/workbooks_endpoint.py | 39 +++++++++-- test/assets/workbook_add_tag.xml | 6 ++ test/test_workbook.py | 68 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 test/assets/workbook_add_tag.xml diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 30f8ce036..2614d0e4d 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -8,10 +8,10 @@ from tableauserverclient.helpers.headers import fix_filename -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger from tableauserverclient.filesys_helpers import ( to_filename, @@ -24,9 +24,11 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, List, Optional, Sequence, + Set, Tuple, TYPE_CHECKING, Union, @@ -498,3 +500,32 @@ def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + + @api(version="1.0") + def add_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + workbook = getattr(workbook, "id", workbook) + + if not isinstance(workbook, str): + raise ValueError("Workbook ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + return self._resource_tagger._add_tags(self.baseurl, workbook, tag_set) + + @api(version="1.0") + def delete_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + workbook = getattr(workbook, "id", workbook) + + if not isinstance(workbook, str): + raise ValueError("Workbook ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + for tag in tag_set: + self._resource_tagger._delete_tag(self.baseurl, workbook, tag) diff --git a/test/assets/workbook_add_tag.xml b/test/assets/workbook_add_tag.xml new file mode 100644 index 000000000..567e3f6fa --- /dev/null +++ b/test/assets/workbook_add_tag.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..c807043f6 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,6 +18,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +ADD_TAG_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tag.xml") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") @@ -894,3 +895,70 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_add_tags(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = list("abcd") + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAGS_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_add_tag(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = "a" + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAG_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_add_tag_id(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = "a" + + with requests_mock.mock() as m: + m.put( + f"{self.baseurl}/{workbook.id}/tags", + status_code=200, + text=Path(ADD_TAG_XML).read_text(), + ) + tag_result = self.server.workbooks.add_tags(workbook.id, tags) + + for a, b in zip(sorted(tag_result), sorted(tags)): + self.assertEqual(a, b) + + def test_delete_tags(self) -> None: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + tags = list("abcd") + + matcher = re.compile(rf"{self.baseurl}\/{workbook.id}\/tags\/[abcd]") + with requests_mock.mock() as m: + m.delete( + matcher, + status_code=200, + text="", + ) + self.server.workbooks.delete_tags(workbook, tags) + history = m.request_history + + self.assertEqual(len(history), len(tags)) + urls = sorted([r.url.split("/")[-1] for r in history]) + self.assertEqual(urls, sorted(tags)) From 35672c32ae9f359610f92b2161099674ac95c144 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:27:27 -0500 Subject: [PATCH 458/567] refactor: use mixin class for tagging endpoints --- .../server/endpoint/resource_tagger.py | 61 ++++++++++- .../server/endpoint/workbooks_endpoint.py | 34 +----- test/assets/workbook_add_tag.xml | 6 -- test/assets/workbook_add_tags.xml | 9 -- test/test_tagging.py | 102 ++++++++++++++++++ 5 files changed, 165 insertions(+), 47 deletions(-) delete mode 100644 test/assets/workbook_add_tag.xml delete mode 100644 test/assets/workbook_add_tags.xml create mode 100644 test/test_tagging.py diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 8177bd733..2d70bbe5c 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,9 +1,11 @@ +import abc import copy +from typing import Generic, Iterable, Set, TypeVar, Union import urllib.parse -from .endpoint import Endpoint -from .exceptions import ServerResponseError -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem @@ -49,3 +51,56 @@ def update_tags(self, baseurl, resource_item): resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) logger.info("Updated tags to {0}".format(resource_item.tags)) + + +T = TypeVar("T") + + +class TaggingMixin(Generic[T]): + @abc.abstractmethod + def baseurl(self) -> str: + raise NotImplementedError("baseurl must be implemented.") + + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}/{item_id}/tags" + add_req = RequestFactory.Tag.add_req(tag_set) + server_response = self.put_request(url, add_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + for tag in tag_set: + encoded_tag_name = urllib.parse.quote(tag) + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" + self.delete_request(url) + + def update_tags(self, item: T) -> None: + if item.tags == item._initial_tags: + return + + add_set = item.tags - item._initial_tags + remove_set = item._initial_tags - item.tags + self.delete_tags(item, remove_set) + if add_set: + item.tags = self.add_tags(item, add_set) + item._initial_tags = copy.copy(item.tags) + logger.info(f"Updated tags to {item.tags}") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 2614d0e4d..53f6352f9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.filesys_helpers import ( to_filename, @@ -58,7 +58,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -501,31 +501,7 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) - @api(version="1.0") - def add_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: - workbook = getattr(workbook, "id", workbook) - if not isinstance(workbook, str): - raise ValueError("Workbook ID not found.") - - if isinstance(tags, str): - tag_set = set([tags]) - else: - tag_set = set(tags) - - return self._resource_tagger._add_tags(self.baseurl, workbook, tag_set) - - @api(version="1.0") - def delete_tags(self, workbook: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: - workbook = getattr(workbook, "id", workbook) - - if not isinstance(workbook, str): - raise ValueError("Workbook ID not found.") - - if isinstance(tags, str): - tag_set = set([tags]) - else: - tag_set = set(tags) - - for tag in tag_set: - self._resource_tagger._delete_tag(self.baseurl, workbook, tag) +Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) +Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) +Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) diff --git a/test/assets/workbook_add_tag.xml b/test/assets/workbook_add_tag.xml deleted file mode 100644 index 567e3f6fa..000000000 --- a/test/assets/workbook_add_tag.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/test/assets/workbook_add_tags.xml b/test/assets/workbook_add_tags.xml deleted file mode 100644 index 8af59ecc9..000000000 --- a/test/assets/workbook_add_tags.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/test/test_tagging.py b/test/test_tagging.py new file mode 100644 index 000000000..b05d4b414 --- /dev/null +++ b/test/test_tagging.py @@ -0,0 +1,102 @@ +import re +from typing import Iterable +from xml.etree import ElementTree as ET + +import pytest +import requests_mock +import tableauserverclient as TSC + + +@pytest.fixture +def get_server() -> TSC.Server: + server = TSC.Server("http://test", False) + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.28" + return server + + +def xml_response_factory(tags: Iterable[str]) -> str: + root = ET.Element("tsResponse") + tags_element = ET.SubElement(root, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + +def make_workbook() -> TSC.WorkbookItem: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + return workbook + + +@pytest.mark.parametrize( + "endpoint_type, item", + [ + ("workbooks", make_workbook()), + ], +) +@pytest.mark.parametrize( + "tags", + [ + "a", + ["a", "b"], + ], +) +def test_add_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + with requests_mock.mock() as m: + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + tag_result = endpoint.add_tags(item, tags) + + if isinstance(tags, str): + tags = [tags] + assert set(tag_result) == set(tags) + + +@pytest.mark.parametrize( + "endpoint_type, item", + [ + ("workbooks", make_workbook()), + ], +) +@pytest.mark.parametrize( + "tags", + [ + "a", + ["a", "b"], + ], +) +def test_delete_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + if isinstance(tags, str): + tags = [tags] + tag_paths = "|".join(tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + with requests_mock.mock() as m: + m.delete( + matcher, + status_code=200, + text=add_tags_xml, + ) + endpoint.delete_tags(item, tags) + history = m.request_history + + assert len(history) == len(tags) + urls = sorted([r.url.split("/")[-1] for r in history]) + assert set(urls) == set(tags) From 8fb818957c4495cb1759dacb40f5f02cc55d9922 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:50:53 -0500 Subject: [PATCH 459/567] style: mypy and black --- .../server/endpoint/resource_tagger.py | 30 ++++---- .../server/endpoint/workbooks_endpoint.py | 8 +-- test/assets/workbook_add_tags.xml | 9 +++ test/test_workbook.py | 68 ------------------- 4 files changed, 28 insertions(+), 87 deletions(-) create mode 100644 test/assets/workbook_add_tags.xml diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 2d70bbe5c..36bfee29c 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Generic, Iterable, Set, TypeVar, Union +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint @@ -53,15 +53,15 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) -T = TypeVar("T") +@runtime_checkable +class Taggable(Protocol): + _initial_tags: Set[str] + id: Optional[str] = None + tags: Set[str] -class TaggingMixin(Generic[T]): - @abc.abstractmethod - def baseurl(self) -> str: - raise NotImplementedError("baseurl must be implemented.") - - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: +class TaggingMixin: + def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -72,12 +72,12 @@ def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[ else: tag_set = set(tags) - url = f"{self.baseurl}/{item_id}/tags" + url = f"{self.baseurl}/{item_id}/tags" # type: ignore add_req = RequestFactory.Tag.add_req(tag_set) - server_response = self.put_request(url, add_req) - return TagItem.from_response(server_response.content, self.parent_srv.namespace) + server_response = self.put_request(url, add_req) # type: ignore + return TagItem.from_response(server_response.content, self.parent_srv.namespace) # type: ignore - def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -90,10 +90,10 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N for tag in tag_set: encoded_tag_name = urllib.parse.quote(tag) - url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" - self.delete_request(url) + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" # type: ignore + self.delete_request(url) # type: ignore - def update_tags(self, item: T) -> None: + def update_tags(self, item: Taggable) -> None: if item.tags == item._initial_tags: return diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 53f6352f9..3051ca6b6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -58,7 +58,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -502,6 +502,6 @@ def schedule_extract_refresh( return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) -Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) -Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) -Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) +Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) # type: ignore +Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) # type: ignore +Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) # type: ignore diff --git a/test/assets/workbook_add_tags.xml b/test/assets/workbook_add_tags.xml new file mode 100644 index 000000000..8af59ecc9 --- /dev/null +++ b/test/assets/workbook_add_tags.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index c807043f6..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -18,7 +18,6 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -ADD_TAG_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tag.xml") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") @@ -895,70 +894,3 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) - - def test_add_tags(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = list("abcd") - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAGS_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_add_tag(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = "a" - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAG_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_add_tag_id(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = "a" - - with requests_mock.mock() as m: - m.put( - f"{self.baseurl}/{workbook.id}/tags", - status_code=200, - text=Path(ADD_TAG_XML).read_text(), - ) - tag_result = self.server.workbooks.add_tags(workbook.id, tags) - - for a, b in zip(sorted(tag_result), sorted(tags)): - self.assertEqual(a, b) - - def test_delete_tags(self) -> None: - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - tags = list("abcd") - - matcher = re.compile(rf"{self.baseurl}\/{workbook.id}\/tags\/[abcd]") - with requests_mock.mock() as m: - m.delete( - matcher, - status_code=200, - text="", - ) - self.server.workbooks.delete_tags(workbook, tags) - history = m.request_history - - self.assertEqual(len(history), len(tags)) - urls = sorted([r.url.split("/")[-1] for r in history]) - self.assertEqual(urls, sorted(tags)) From 8f1ff4c256b2c0ff82abf7f5f7b24d8d015961fb Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:54:57 -0500 Subject: [PATCH 460/567] feat: add tagging support to views --- .../server/endpoint/views_endpoint.py | 16 ++++++++++------ test/test_tagging.py | 7 +++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f98eb1cd7..293a12e63 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,10 +1,10 @@ import logging from contextlib import closing -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import ViewItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -21,7 +21,7 @@ ) -class Views(QuerysetEndpoint[ViewItem]): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -152,7 +152,7 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque yield from server_response.iter_content(1024) @api(version="3.2") - def populate_permissions(self, item: ViewItem) -> None: + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -173,3 +173,7 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + +Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore +Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore +Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index b05d4b414..8c8defe83 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -33,11 +33,17 @@ def make_workbook() -> TSC.WorkbookItem: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" return workbook +def make_view() -> TSC.ViewItem: + view = TSC.ViewItem() + view._id = "06b944d2-959d-4604-9305-12323c95e70e" + return view + @pytest.mark.parametrize( "endpoint_type, item", [ ("workbooks", make_workbook()), + ("views", make_view()), ], ) @pytest.mark.parametrize( @@ -69,6 +75,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("views", make_view()), ], ) @pytest.mark.parametrize( From ee771d80ab18edd14a09d598942fc1bcc36d5032 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:57:16 -0500 Subject: [PATCH 461/567] style: black --- tableauserverclient/server/endpoint/views_endpoint.py | 3 ++- test/test_tagging.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 293a12e63..a95f9bf60 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -152,7 +152,7 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque yield from server_response.iter_content(1024) @api(version="3.2") - def populate_permissions(self, item: ViewItem) -> None: + def populate_permissions(self, item: ViewItem) -> None: self._permissions.populate(item) @api(version="3.2") @@ -174,6 +174,7 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index 8c8defe83..966ef04b7 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -33,6 +33,7 @@ def make_workbook() -> TSC.WorkbookItem: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" return workbook + def make_view() -> TSC.ViewItem: view = TSC.ViewItem() view._id = "06b944d2-959d-4604-9305-12323c95e70e" From a625382bfa86398d00dd5e144132aeabc822afb2 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 1 Aug 2024 22:59:46 -0500 Subject: [PATCH 462/567] feat: add tagging to datasource endpoint --- .../server/endpoint/datasources_endpoint.py | 9 +++++++-- test/test_tagging.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..d15c5ee20 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -19,7 +19,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( @@ -54,7 +54,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint[DatasourceItem]): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -459,3 +459,8 @@ def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + + +Datasources.add_tags = api(version="1.0")(Datasources.add_tags) # type: ignore +Datasources.delete_tags = api(version="1.0")(Datasources.delete_tags) # type: ignore +Datasources.update_tags = api(version="1.0")(Datasources.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index 966ef04b7..64ad7013c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -40,11 +40,18 @@ def make_view() -> TSC.ViewItem: return view +def make_datasource() -> TSC.DatasourceItem: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + return datasource + + @pytest.mark.parametrize( "endpoint_type, item", [ ("workbooks", make_workbook()), ("views", make_view()), + ("datasources", make_datasource()), ], ) @pytest.mark.parametrize( @@ -77,6 +84,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: [ ("workbooks", make_workbook()), ("views", make_view()), + ("datasources", make_datasource()), ], ) @pytest.mark.parametrize( From 3209f3ad4febbf50085c0a860a93bcc5d5f8e7d6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 06:57:49 -0500 Subject: [PATCH 463/567] test: add/delete tags with just object id --- test/test_tagging.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_tagging.py b/test/test_tagging.py index 64ad7013c..c9f98fce8 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -50,8 +50,11 @@ def make_datasource() -> TSC.DatasourceItem: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("workbooks", "some_id"), ("views", make_view()), + ("views", "some_id"), ("datasources", make_datasource()), + ("datasources", "some_id"), ], ) @pytest.mark.parametrize( @@ -83,8 +86,11 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: "endpoint_type, item", [ ("workbooks", make_workbook()), + ("workbooks", "some_id"), ("views", make_view()), + ("views", "some_id"), ("datasources", make_datasource()), + ("datasources", "some_id"), ], ) @pytest.mark.parametrize( From 1b458163810263b9f3739e9442b592e71c46bf18 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:06:06 -0500 Subject: [PATCH 464/567] feat: tag tables --- .../server/endpoint/tables_endpoint.py | 18 ++++++++++++------ test/test_tagging.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b4c5181e9..d3b53897f 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,17 +1,18 @@ import logging -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import TableItem, ColumnItem, PaginationItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -class Tables(Endpoint): +class Tables(Endpoint, TaggingMixin): def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) @@ -124,3 +125,8 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + +Tables.add_tags = api(version="3.9")(Tables.add_tags) # type: ignore +Tables.delete_tags = api(version="3.9")(Tables.delete_tags) # type: ignore +Tables.update_tags = api(version="3.9")(Tables.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index c9f98fce8..c91ddb93c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -46,6 +46,12 @@ def make_datasource() -> TSC.DatasourceItem: return datasource +def make_table() -> TSC.TableItem: + table = TSC.TableItem("project", "test") + table._id = "06b944d2-959d-4604-9305-12323c95e70e" + return table + + @pytest.mark.parametrize( "endpoint_type, item", [ @@ -55,6 +61,8 @@ def make_datasource() -> TSC.DatasourceItem: ("views", "some_id"), ("datasources", make_datasource()), ("datasources", "some_id"), + ("tables", make_table()), + ("tables", "some_id"), ], ) @pytest.mark.parametrize( @@ -91,6 +99,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ("views", "some_id"), ("datasources", make_datasource()), ("datasources", "some_id"), + ("tables", make_table()), + ("tables", "some_id"), ], ) @pytest.mark.parametrize( From 5dae8bb5f8075c851b1853896c9dce6c2c6104d6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:12:03 -0500 Subject: [PATCH 465/567] feat: tag databases --- .../server/endpoint/databases_endpoint.py | 18 ++++++++++++------ test/test_tagging.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 849072a17..ecdb3f334 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,17 +1,18 @@ import logging -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource from tableauserverclient.helpers.logging import logger -class Databases(Endpoint): +class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): super(Databases, self).__init__(parent_srv) @@ -123,3 +124,8 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + +Databases.add_tags = api(version="3.9")(Databases.add_tags) # type: ignore +Databases.delete_tags = api(version="3.9")(Databases.delete_tags) # type: ignore +Databases.update_tags = api(version="3.9")(Databases.update_tags) # type: ignore diff --git a/test/test_tagging.py b/test/test_tagging.py index c91ddb93c..84f3da976 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -52,6 +52,12 @@ def make_table() -> TSC.TableItem: return table +def make_database() -> TSC.DatabaseItem: + database = TSC.DatabaseItem("project", "test") + database._id = "06b944d2-959d-4604-9305-12323c95e70e" + return database + + @pytest.mark.parametrize( "endpoint_type, item", [ @@ -63,6 +69,8 @@ def make_table() -> TSC.TableItem: ("datasources", "some_id"), ("tables", make_table()), ("tables", "some_id"), + ("databases", make_database()), + ("databases", "some_id"), ], ) @pytest.mark.parametrize( @@ -101,6 +109,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ("datasources", "some_id"), ("tables", make_table()), ("tables", "some_id"), + ("databases", make_database()), + ("databases", "some_id"), ], ) @pytest.mark.parametrize( From 56301e9667c4535c870b0bf2b4d7a5be6c5a6ca0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:16:46 -0500 Subject: [PATCH 466/567] chore: unused imports --- tableauserverclient/server/endpoint/resource_tagger.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 36bfee29c..0e8ddb7ac 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,5 @@ -import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, runtime_checkable +from typing import Iterable, Optional, Protocol, Set, Union, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint From b7e6d13592d9a0e0852032ca3e6f0ec133b1f5a5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:35:20 -0500 Subject: [PATCH 467/567] fix: clean up delete_tags test comparison --- test/test_tagging.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 84f3da976..5687919b0 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -140,5 +140,5 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: history = m.request_history assert len(history) == len(tags) - urls = sorted([r.url.split("/")[-1] for r in history]) - assert set(urls) == set(tags) + urls = {r.url.split("/")[-1] for r in history} + assert urls == set(tags) From 9f6e151fd4d142e9a577e12bbe5b5cfd8b2f14ac Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 21:49:04 -0500 Subject: [PATCH 468/567] feat: batch tag create and delete --- .../server/endpoint/__init__.py | 2 + .../server/endpoint/resource_tagger.py | 50 +++++++++++++- tableauserverclient/server/request_factory.py | 21 +++++- tableauserverclient/server/server.py | 2 + test/test_tagging.py | 67 ++++++++++++++++--- 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index e6b50b27d..7b89339bc 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -22,6 +22,7 @@ from tableauserverclient.server.endpoint.sites_endpoint import Sites from tableauserverclient.server.endpoint.subscriptions_endpoint import Subscriptions from tableauserverclient.server.endpoint.tables_endpoint import Tables +from tableauserverclient.server.endpoint.resource_tagger import Tags from tableauserverclient.server.endpoint.tasks_endpoint import Tasks from tableauserverclient.server.endpoint.users_endpoint import Users from tableauserverclient.server.endpoint.views_endpoint import Views @@ -55,6 +56,7 @@ "Sites", "Subscriptions", "Tables", + "Tags", "Tasks", "Users", "Views", diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 0e8ddb7ac..b5d97721e 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,8 +1,8 @@ import copy -from typing import Iterable, Optional, Protocol, Set, Union, runtime_checkable +from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable import urllib.parse -from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory @@ -10,6 +10,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.column_item import ColumnItem + from tableauserverclient.models.database_item import DatabaseItem + from tableauserverclient.models.datasource_item import DatasourceItem + from tableauserverclient.models.flow_item import FlowItem + from tableauserverclient.models.table_item import TableItem + from tableauserverclient.models.workbook_item import WorkbookItem + from tableauserverclient.server.server import Server + class _ResourceTagger(Endpoint): # Add new tags to resource @@ -103,3 +112,40 @@ def update_tags(self, item: Taggable) -> None: item.tags = self.add_tags(item, add_set) item._initial_tags = copy.copy(item.tags) logger.info(f"Updated tags to {item.tags}") + + +content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + +class Tags(Endpoint): + def __init__(self, parent_srv: "Server"): + super().__init__(parent_srv) + + @property + def baseurl(self): + return f"{self.parent_srv.baseurl}/tags" + + @api(version="3.9") + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchCreate" + batch_create_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_create_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.9") + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchDelete" + # The batch delete XML is the same as the batch create XML. + batch_delete_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_delete_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e965411cf..a83234390 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, TYPE_CHECKING from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata @@ -861,6 +861,9 @@ def update_req(self, table_item): return ET.tostring(xml_request) +content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") @@ -870,6 +873,22 @@ def add_req(self, tag_set): tag_element.attrib["label"] = tag return ET.tostring(xml_request) + @_tsrequest_wrapped + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + tag_batch = ET.SubElement(element, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_element = ET.SubElement(contents_element, "content") + if item.id is None: + raise ValueError(f"Item {item} must have an ID to be tagged.") + content_element.attrib["id"] = item.id + + return ET.tostring(element) + class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 18d67fa07..1de865ba8 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -34,6 +34,7 @@ Endpoint, CustomViews, GroupSets, + Tags, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -101,6 +102,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.metrics = Metrics(self) self.custom_views = CustomViews(self) self.group_sets = GroupSets(self) + self.tags = Tags(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/test_tagging.py b/test/test_tagging.py index 5687919b0..82936893c 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,10 +1,12 @@ import re from typing import Iterable +import uuid from xml.etree import ElementTree as ET import pytest import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.resource_tagger import content @pytest.fixture @@ -18,7 +20,7 @@ def get_server() -> TSC.Server: return server -def xml_response_factory(tags: Iterable[str]) -> str: +def add_tag_xml_response_factory(tags: Iterable[str]) -> str: root = ET.Element("tsResponse") tags_element = ET.SubElement(root, "tags") for tag in tags: @@ -28,33 +30,50 @@ def xml_response_factory(tags: Iterable[str]) -> str: return ET.tostring(root, encoding="utf-8").decode("utf-8") +def batch_add_tags_xml_response_factory(tags, content): + root = ET.Element("tsResponse") + tag_batch = ET.SubElement(root, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_elem = ET.SubElement(contents_element, "content") + content_elem.attrib["id"] = item.id or "some_id" + t = item.__class__.__name__.replace("Item", "") or "" + content_elem.attrib["contentType"] = t + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + def make_workbook() -> TSC.WorkbookItem: workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + workbook._id = str(uuid.uuid4()) return workbook def make_view() -> TSC.ViewItem: view = TSC.ViewItem() - view._id = "06b944d2-959d-4604-9305-12323c95e70e" + view._id = str(uuid.uuid4()) return view def make_datasource() -> TSC.DatasourceItem: datasource = TSC.DatasourceItem("project", "test") - datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + datasource._id = str(uuid.uuid4()) return datasource def make_table() -> TSC.TableItem: table = TSC.TableItem("project", "test") - table._id = "06b944d2-959d-4604-9305-12323c95e70e" + table._id = str(uuid.uuid4()) return table def make_database() -> TSC.DatabaseItem: database = TSC.DatabaseItem("project", "test") - database._id = "06b944d2-959d-4604-9305-12323c95e70e" + database._id = str(uuid.uuid4()) return database @@ -81,7 +100,7 @@ def make_database() -> TSC.DatabaseItem: ], ) def test_add_tags(get_server, endpoint_type, item, tags) -> None: - add_tags_xml = xml_response_factory(tags) + add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) @@ -121,7 +140,7 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: ], ) def test_delete_tags(get_server, endpoint_type, item, tags) -> None: - add_tags_xml = xml_response_factory(tags) + add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) @@ -142,3 +161,35 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: assert len(history) == len(tags) urls = {r.url.split("/")[-1] for r in history} assert urls == set(tags) + + +def test_tags_batch_add(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchCreate", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_add(tags, content) + + assert set(tag_result) == set(tags) + + +def test_tags_batch_delete(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchDelete", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_delete(tags, content) + + assert set(tag_result) == set(tags) From 88b124f921596cd2043b74ce4615e3955623dd59 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:01:15 -0500 Subject: [PATCH 469/567] chore: cleanup typing for TaggingMixin --- .../server/endpoint/resource_tagger.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index b5d97721e..8796fdd10 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,3 +1,4 @@ +import abc import copy from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable import urllib.parse @@ -68,7 +69,26 @@ class Taggable(Protocol): tags: Set[str] -class TaggingMixin: +class Response(Protocol): + content: bytes + + +class TaggingMixin(abc.ABC): + parent_srv: "Server" + + @property + @abc.abstractmethod + def baseurl(self) -> str: + pass + + @abc.abstractmethod + def put_request(self, url, request) -> Response: + pass + + @abc.abstractmethod + def delete_request(self, url) -> None: + pass + def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) @@ -80,10 +100,10 @@ def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) else: tag_set = set(tags) - url = f"{self.baseurl}/{item_id}/tags" # type: ignore + url = f"{self.baseurl}/{item_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) - server_response = self.put_request(url, add_req) # type: ignore - return TagItem.from_response(server_response.content, self.parent_srv.namespace) # type: ignore + server_response = self.put_request(url, add_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) @@ -98,8 +118,8 @@ def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str for tag in tag_set: encoded_tag_name = urllib.parse.quote(tag) - url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" # type: ignore - self.delete_request(url) # type: ignore + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" + self.delete_request(url) def update_tags(self, item: Taggable) -> None: if item.tags == item._initial_tags: From f99ef0ee6b867e19bc4356b5362fd7bb43f18303 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:08:49 -0500 Subject: [PATCH 470/567] fix: id in Taggable protocol --- tableauserverclient/server/endpoint/resource_tagger.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 8796fdd10..b837f644d 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -65,9 +65,12 @@ def update_tags(self, baseurl, resource_item): @runtime_checkable class Taggable(Protocol): _initial_tags: Set[str] - id: Optional[str] = None tags: Set[str] + @property + def id(self) -> Optional[str]: + pass + class Response(Protocol): content: bytes From 8875c8b642cd511b6927e605c4087f427e25f932 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:09:07 -0500 Subject: [PATCH 471/567] chore: replace resource_tagger with mixin call --- .../server/endpoint/workbooks_endpoint.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 3051ca6b6..7b06ae305 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -24,11 +24,9 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, List, Optional, Sequence, - Set, Tuple, TYPE_CHECKING, Union, @@ -37,8 +35,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server from tableauserverclient.server.request_options import RequestOptions - from tableauserverclient.models import DatasourceItem, ConnectionCredentials - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DatasourceItem + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -61,7 +59,6 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @@ -149,7 +146,7 @@ def update( error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, workbook_item) + self.update_tags(workbook_item) # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) From da4eb907e8cf6a574c20e008b1954075f365687c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:19:11 -0500 Subject: [PATCH 472/567] chore: switch to using mixin methods --- tableauserverclient/server/endpoint/datasources_endpoint.py | 5 ++--- tableauserverclient/server/endpoint/views_endpoint.py | 5 ++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index d15c5ee20..45cfb005d 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -19,7 +19,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( @@ -57,7 +57,6 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -149,7 +148,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: ) raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, datasource_item) + self.update_tags(datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index a95f9bf60..fe99b3b3f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -4,7 +4,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.models import ViewItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -24,7 +24,6 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @@ -169,7 +168,7 @@ def update(self, view_item: ViewItem) -> ViewItem: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, view_item) + self.update_tags(view_item) # Returning view item to stay consistent with datasource/view update functions return view_item diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 7b06ae305..9c664e204 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.filesys_helpers import ( to_filename, From 1a9a5abc58474e7233c757c882a6f4c8b5afb959 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:29:22 -0500 Subject: [PATCH 473/567] refactor: tag test parameter extraction --- test/test_tagging.py | 49 +++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 82936893c..4263f1380 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -77,8 +77,7 @@ def make_database() -> TSC.DatabaseItem: return database -@pytest.mark.parametrize( - "endpoint_type, item", +sample_taggable_items = ( [ ("workbooks", make_workbook()), ("workbooks", "some_id"), @@ -92,13 +91,16 @@ def make_database() -> TSC.DatabaseItem: ("databases", "some_id"), ], ) -@pytest.mark.parametrize( - "tags", - [ - "a", - ["a", "b"], - ], -) + +sample_tags = [ + "a", + ["a", "b"], + ["a", "b", "c", "c"], +] + + +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) def test_add_tags(get_server, endpoint_type, item, tags) -> None: add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) @@ -117,28 +119,8 @@ def test_add_tags(get_server, endpoint_type, item, tags) -> None: assert set(tag_result) == set(tags) -@pytest.mark.parametrize( - "endpoint_type, item", - [ - ("workbooks", make_workbook()), - ("workbooks", "some_id"), - ("views", make_view()), - ("views", "some_id"), - ("datasources", make_datasource()), - ("datasources", "some_id"), - ("tables", make_table()), - ("tables", "some_id"), - ("databases", make_database()), - ("databases", "some_id"), - ], -) -@pytest.mark.parametrize( - "tags", - [ - "a", - ["a", "b"], - ], -) +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) def test_delete_tags(get_server, endpoint_type, item, tags) -> None: add_tags_xml = add_tag_xml_response_factory(tags) endpoint = getattr(get_server, endpoint_type) @@ -158,9 +140,10 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: endpoint.delete_tags(item, tags) history = m.request_history - assert len(history) == len(tags) + tag_set = set(tags) + assert len(history) == len(tag_set) urls = {r.url.split("/")[-1] for r in history} - assert urls == set(tags) + assert urls == tag_set def test_tags_batch_add(get_server) -> None: From 28503b501dd8d4bb39859c320ea439794bba41be Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 2 Aug 2024 22:40:32 -0500 Subject: [PATCH 474/567] feat: add tagging to flows --- tableauserverclient/server/endpoint/flows_endpoint.py | 4 ++-- test/test_tagging.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 858ff91ac..dd19fc0ef 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -13,7 +13,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( @@ -50,7 +50,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint[FlowItem]): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/test/test_tagging.py b/test/test_tagging.py index 4263f1380..d3f23d40e 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -77,6 +77,12 @@ def make_database() -> TSC.DatabaseItem: return database +def make_flow() -> TSC.FlowItem: + flow = TSC.FlowItem("project", "test") + flow._id = str(uuid.uuid4()) + return flow + + sample_taggable_items = ( [ ("workbooks", make_workbook()), @@ -89,6 +95,8 @@ def make_database() -> TSC.DatabaseItem: ("tables", "some_id"), ("databases", make_database()), ("databases", "some_id"), + ("flows", make_flow()), + ("flows", "some_id"), ], ) From cf7bce7412ad6bd2bb06542c999189590ccffe55 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 3 Aug 2024 06:37:21 -0500 Subject: [PATCH 475/567] chore: use overrides to apply decorator --- .../server/endpoint/databases_endpoint.py | 14 +++++++++++--- .../server/endpoint/datasources_endpoint.py | 15 +++++++++++---- .../server/endpoint/resource_tagger.py | 10 ++++++++-- .../server/endpoint/tables_endpoint.py | 13 ++++++++++--- .../server/endpoint/views_endpoint.py | 17 ++++++++++++----- .../server/endpoint/workbooks_endpoint.py | 15 ++++++++++++--- 6 files changed, 64 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index ecdb3f334..2f8fece07 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union, Iterable, Set from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -125,7 +126,14 @@ def add_dqw(self, item, warning): def delete_dqw(self, item): self._data_quality_warnings.clear(item) + @api(version="3.9") + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + return super().add_tags(item, tags) -Databases.add_tags = api(version="3.9")(Databases.add_tags) # type: ignore -Databases.delete_tags = api(version="3.9")(Databases.delete_tags) # type: ignore -Databases.update_tags = api(version="3.9")(Databases.update_tags) # type: ignore + @api(version="3.9") + def delete_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> None: + super().delete_tags(item, tags) + + @api(version="3.9") + def update_tags(self, item: DatabaseItem) -> None: + raise NotImplementedError("Update tags is not supported for databases.") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 45cfb005d..a528d5e67 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename @@ -459,7 +459,14 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + @api(version="1.0") + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Datasources.add_tags = api(version="1.0")(Datasources.add_tags) # type: ignore -Datasources.delete_tags = api(version="1.0")(Datasources.delete_tags) # type: ignore -Datasources.update_tags = api(version="1.0")(Datasources.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: DatasourceItem) -> None: + return super().update_tags(item) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index b837f644d..f6b1cab05 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -62,6 +62,12 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) +class HasID(Protocol): + @property + def id(self) -> Optional[str]: + pass + + @runtime_checkable class Taggable(Protocol): _initial_tags: Set[str] @@ -92,7 +98,7 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -108,7 +114,7 @@ def add_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) - def delete_tags(self, item: Union[Taggable, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index d3b53897f..b2e41df8b 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Iterable, Set, Union from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -126,7 +127,13 @@ def add_dqw(self, item, warning): def delete_dqw(self, item): self._data_quality_warnings.clear(item) + @api(version="3.9") + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Tables.add_tags = api(version="3.9")(Tables.add_tags) # type: ignore -Tables.delete_tags = api(version="3.9")(Tables.delete_tags) # type: ignore -Tables.update_tags = api(version="3.9")(Tables.update_tags) # type: ignore + @api(version="3.9") + def delete_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + def update_tags(self, item: TableItem) -> None: # type: ignore + raise NotImplementedError("Update tags is not implemented for TableItem") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index fe99b3b3f..4a4d836e2 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -9,10 +9,10 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: - from ..request_options import ( + from tableauserverclient.server.request_options import ( RequestOptions, CSVRequestOptions, PDFRequestOptions, @@ -173,7 +173,14 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + @api(version="1.0") + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Views.add_tags = api(version="1.0")(Views.add_tags) # type: ignore -Views.delete_tags = api(version="1.0")(Views.delete_tags) # type: ignore -Views.update_tags = api(version="1.0")(Views.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: ViewItem) -> None: + return super().update_tags(item) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 9c664e204..9b48ecc15 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -24,9 +24,11 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, List, Optional, Sequence, + Set, Tuple, TYPE_CHECKING, Union, @@ -498,7 +500,14 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + @api(version="1.0") + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) -Workbooks.add_tags = api(version="1.0")(Workbooks.add_tags) # type: ignore -Workbooks.delete_tags = api(version="1.0")(Workbooks.delete_tags) # type: ignore -Workbooks.update_tags = api(version="1.0")(Workbooks.update_tags) # type: ignore + @api(version="1.0") + def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: WorkbookItem) -> None: + return super().update_tags(item) From c84f921650ea4298a5333a2e6efbbc2e51776479 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 3 Aug 2024 21:55:44 -0500 Subject: [PATCH 476/567] feat: download custom views --- .../server/endpoint/custom_views_endpoint.py | 34 +++++++++++++++++-- test/test_custom_view.py | 18 ++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index d1446b1fe..c2362dde9 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,8 +1,10 @@ +import io import logging -from typing import List, Optional, Tuple +import os +from typing import List, Optional, Tuple, Union -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +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 @@ -16,6 +18,15 @@ update the name or owner of a custom view. """ +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] +io_types_r = (io.BufferedReader, io.BytesIO) +io_types_w = (io.BufferedWriter, io.BytesIO) + class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): @@ -25,6 +36,10 @@ def __init__(self, parent_srv): def baseurl(self) -> str: return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def expurl(self) -> str: + return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" + """ If the request has no filter parameters: Administrators will see all custom views. Other users will see only custom views that they own. @@ -102,3 +117,16 @@ def delete(self, view_id: str) -> None: url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) logger.info("Deleted single custom view (ID: {0})".format(view_id)) + + @api(version="3.21") + def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + url = f"{self.expurl}/{view_item.id}/content" + server_response = self.get_request(url) + if isinstance(file, io_types_w): + file.write(server_response.content) + return file + + with open(file, "wb") as f: + f.write(server_response.content) + + return file diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 55dec5df1..e1150a6ea 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -1,4 +1,6 @@ +import io import os +from pathlib import Path import unittest import requests_mock @@ -6,18 +8,19 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") 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_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" class CustomViewTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.19" # custom views only introduced in 3.19 + self.server.version = "3.21" # custom views only introduced in 3.19 # Fake sign in self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -132,3 +135,14 @@ def test_update(self) -> None: def test_update_missing_id(self) -> None: cv = TSC.CustomViewItem(name="test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) + + def test_download(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._id = "1f951daf-4061-451a-9df1-69a8062664f2" + content = CUSTOM_VIEW_DOWNLOAD.read_bytes() + data = io.BytesIO() + with requests_mock.mock() as m: + m.get(f"{self.server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content) + self.server.custom_views.download(cv, data) + + assert data.getvalue() == content From da501d629e5af5543c84ebfcde534b1609547e68 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 4 Aug 2024 14:17:49 -0500 Subject: [PATCH 477/567] feat: publish custom views --- .../server/endpoint/custom_views_endpoint.py | 33 ++++++ tableauserverclient/server/request_factory.py | 44 ++++++++ test/test_custom_view.py | 100 ++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index c2362dde9..57a5b0100 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,8 +1,11 @@ import io import logging import os +from pathlib import Path from typing import List, Optional, Tuple, Union +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +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 @@ -130,3 +133,33 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: f.write(server_response.content) return file + + @api(version="3.21") + def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + url = self.expurl + if isinstance(file, io_types_r): + size = get_file_object_size(file) + elif isinstance(file, (str, Path)) and (p := Path(file)).is_file(): + size = p.stat().st_size + else: + raise ValueError("File path or file object required for publishing custom view.") + + if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + upload_session_id = self.parent_srv.fileuploads.upload(file) + url = f"{url}?uploadSessionId={upload_session_id}" + xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) + else: + if isinstance(file, io_types_r): + file.seek(0) + contents = file.read() + if view_item.name is None: + raise MissingRequiredFieldError("Custom view item missing name.") + filename = view_item.name + elif isinstance(file, (str, Path)): + filename = Path(file).name + contents = Path(file).read_bytes() + + xml_request, content_type = RequestFactory.CustomView.publish_req(view_item, filename, contents) + + server_response = self.post_request(url, xml_request, content_type) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e965411cf..b0c8b37b0 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET + from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField @@ -1267,6 +1268,49 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): if custom_view_item.name is not None: updating_element.attrib["name"] = custom_view_item.name + @_tsrequest_wrapped + def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem) -> bytes: + custom_view_element = ET.SubElement(xml_request, "customView") + if (name := custom_view_item.name) is not None: + custom_view_element.attrib["name"] = name + else: + raise ValueError(f"Custom View Item missing name: {custom_view_item}") + if (shared := custom_view_item.shared) is not None: + custom_view_element.attrib["shared"] = str(shared).lower() + else: + raise ValueError(f"Custom View Item missing shared: {custom_view_item}") + if (owner := custom_view_item.owner) is not None: + owner_element = ET.SubElement(custom_view_element, "owner") + if (owner_id := owner.id) is not None: + owner_element.attrib["id"] = owner_id + else: + raise ValueError(f"Custom View Item owner missing id: {owner}") + else: + raise ValueError(f"Custom View Item missing owner: {custom_view_item}") + if (workbook := custom_view_item.workbook) is not None: + workbook_element = ET.SubElement(custom_view_element, "workbook") + if (workbook_id := workbook.id) is not None: + workbook_element.attrib["id"] = workbook_id + else: + raise ValueError(f"Custom View Item workbook missing id: {workbook}") + else: + raise ValueError(f"Custom View Item missing workbook: {custom_view_item}") + + return ET.tostring(xml_request) + + def publish_req_chunked(self, custom_view_item: CustomViewItem): + xml_request = self._publish_xml(custom_view_item) + parts = {"request_payload": ("", xml_request, "text/xml")} + return _add_multipart(parts) + + def publish_req(self, custom_view_item: CustomViewItem, filename: str, file_contents: bytes): + xml_request = self._publish_xml(custom_view_item) + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_customview": (filename, file_contents, "application/octet-stream"), + } + return _add_multipart(parts) + class GroupSetRequest: @_tsrequest_wrapped diff --git a/test/test_custom_view.py b/test/test_custom_view.py index e1150a6ea..80800c86b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -1,12 +1,16 @@ +from contextlib import ExitStack import io import os from pathlib import Path +from tempfile import TemporaryDirectory import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import BYTES_PER_MB from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -15,6 +19,8 @@ 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_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" class CustomViewTests(unittest.TestCase): @@ -146,3 +152,97 @@ def test_download(self) -> None: self.server.custom_views.download(cv, data) assert data.getvalue() == content + + def test_publish_filepath(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_str(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD)) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_io(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, data) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_missing_owner_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_publish_missing_wb_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_large_publish(self): + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with ExitStack() as stack: + temp_dir = stack.enter_context(TemporaryDirectory()) + file_path = Path(temp_dir) / "test_file" + file_path.write_bytes(os.urandom(65 * BYTES_PER_MB)) + mock = stack.enter_context(requests_mock.mock()) + # Mock initializing upload + mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text()) + # Mock the upload + mock.put( + f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0", + text=FILE_UPLOAD_APPEND.read_text(), + ) + # Mock the publish + mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + + view = self.server.custom_views.publish(cv, file_path) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None From 17bd73af797ca99b86b941c64a562cd2942d4ec3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 4 Aug 2024 14:21:15 -0500 Subject: [PATCH 478/567] fix: add missing test asset --- test/assets/custom_view_download.json | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/assets/custom_view_download.json diff --git a/test/assets/custom_view_download.json b/test/assets/custom_view_download.json new file mode 100644 index 000000000..1ba2d74b7 --- /dev/null +++ b/test/assets/custom_view_download.json @@ -0,0 +1,47 @@ +[ + { + "isSourceView": true, + "viewName": "Overview", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3ZlcnZpZXcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6Q2F0ZWdvcnk6bmtdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sQ2F0ZWdvcnksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t0bW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbU2VnbWVudF0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sTU9OVEgoT3JkZXIgRGF0ZSksU2VnbWVudCldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFBvc3RhbCBDb2RlLFN0YXRlL1Byb3ZpbmNlKScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6UG9zdGFsIENvZGU6bmtdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTdGF0ZS9Qcm92aW5jZSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzXScgZGVyaXZhdGlvbj0nVXNlcicgbmFtZT0nW3VzcjpDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdUb3RhbCBTYWxlcyc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGUgTWFwJz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW25vbmU6UmVnaW9uOm5rXScgZmlsdGVyLWdyb3VwPScxNCc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW25vbmU6UmVnaW9uOm5rXScgbWVtYmVyPScmcXVvdDtDZW50cmFsJnF1b3Q7JyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdTYWxlcyBieSBTZWdtZW50Jz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXSc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgbWVtYmVyPScmcXVvdDtUZXhhcyZxdW90OycgdXNlcjp1aS1hY3Rpb24tZmlsdGVyPSdbQWN0aW9uMV0nIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOlJlZ2lvbjpua10nIGZpbHRlci1ncm91cD0nMTQnPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tub25lOlJlZ2lvbjpua10nIG1lbWJlcj0nJnF1b3Q7Q2VudHJhbCZxdW90OycgdXNlcjp1aS1kb21haW49J2RhdGFiYXNlJyB1c2VyOnVpLWVudW1lcmF0aW9uPSdpbmNsdXNpdmUnIHVzZXI6dWktbWFya2VyPSdlbnVtZXJhdGUnIC8-CiAgICA8L2ZpbHRlcj4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2FsZXMgYnkgUHJvZHVjdCc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d2luZG93cz4KICAgIDx3aW5kb3cgY2xhc3M9J3dvcmtzaGVldCcgbmFtZT0nU2FsZSBNYXAnPgogICAgICA8c2VsZWN0aW9uLWNvbGxlY3Rpb24-CiAgICAgICAgPHR1cGxlLXNlbGVjdGlvbj4KICAgICAgICAgIDx0dXBsZS1yZWZlcmVuY2U-CiAgICAgICAgICAgIDx0dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICAgIDxwYW5lLWRlc2NyaXB0b3I-CiAgICAgICAgICAgICAgICA8eC1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDwveC1maWVsZHM-CiAgICAgICAgICAgICAgICA8eS1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMYXRpdHVkZSAoZ2VuZXJhdGVkKV08L2ZpZWxkPgogICAgICAgICAgICAgICAgPC95LWZpZWxkcz4KICAgICAgICAgICAgICA8L3BhbmUtZGVzY3JpcHRvcj4KICAgICAgICAgICAgICA8Y29sdW1ucz4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOkNvdW50cnkvUmVnaW9uOm5rXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpTdGF0ZS9Qcm92aW5jZTpua108L2ZpZWxkPgogICAgICAgICAgICAgICAgPGZpZWxkPltmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0dlb21ldHJ5IChnZW5lcmF0ZWQpXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bTGF0aXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLlt1c3I6Q2FsY3VsYXRpb25fOTkyMTEwMzE0NDEwMzc0Mzpxa108L2ZpZWxkPgogICAgICAgICAgICAgIDwvY29sdW1ucz4KICAgICAgICAgICAgPC90dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICA8dHVwbGU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O1VuaXRlZCBTdGF0ZXMmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4mcXVvdDtUZXhhcyZxdW90OzwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O01VTFRJUE9MWUdPTigoKC05Ny4xNDYzIDI1Ljk1NTYsLTk3LjIwOCAyNS45NjM2LC05Ny4yNzcyIDI1LjkzNTQsLTk3LjM0ODkgMjUuOTMwOCwtOTcuMzc0NCAyNS45MDc0LC05Ny4zNTc2IDI1Ljg4NjksLTk3LjM3MzcgMjUuODQsLTk3LjQ1MzkgMjUuODU0NCwtOTcuNDU2NCAyNS44ODM4LC05Ny41MjE4IDI1Ljg4NjUsLTk3LjU0ODIgMjUuOTM1NSwtOTcuNTgyNiAyNS45Mzc5LC05Ny42NDQ5IDI2LjAyNzUsLTk3LjcwNjcgMjYuMDM3NCwtOTcuNzY0MSAyNi4wMjg2LC05Ny44MDEzIDI2LjA2LC05Ny44MzU1IDI2LjA0NjksLTk3Ljg2MTkgMjYuMDY5OCwtOTcuOTA5OSAyNi4wNTY5LC05Ny45NjYxIDI2LjA1MTksLTk4LjAzMDggMjYuMDY1LC05OC4wNzAxIDI2LjAzNzksLTk4LjA3OTEgMjYuMDcwNSwtOTguMTM1NSAyNi4wNzIsLTk4LjE1NzUgMjYuMDU0NCwtOTguMTk3IDI2LjA1NjIsLTk4LjMwNjUgMjYuMTA0MywtOTguMzM1MiAyNi4xMzc2LC05OC4zODY3IDI2LjE1NzksLTk4LjQ0NDMgMjYuMjAxMiwtOTguNDQ1MiAyNi4yMjQ2LC05OC41MDYxIDI2LjIwOSwtOTguNTIyNCAyNi4yMjA5LC05OC41NjE1IDI2LjIyNDUsLTk4LjU4NjcgMjYuMjU3NSwtOTguNjU0MiAyNi4yMzYsLTk4LjY3OTQgMjYuMjQ5MiwtOTguNzUzOCAyNi4zMzE3LC05OC43ODk4IDI2LjMzMTYsLTk4LjgyNjkgMjYuMzY5NiwtOTguODk2MiAyNi4zNTMyLC05OC45MjkyIDI2LjM5MzIsLTk4Ljk0NjUgMjYuMzY5OSwtOTguOTc0MiAyNi40MDExLC05OS4wMTA2IDI2LjM5MjEsLTk5LjA0IDI2LjQxMjksLTk5LjA5NDggMjYuNDEwOSwtOTkuMTEwOSAyNi40MjYzLC05OS4wOTE2IDI2LjQ3NjQsLTk5LjEyODQgMjYuNTI1NSwtOTkuMTY2NyAyNi41MzYxLC05OS4xNjk0IDI2LjU3MTcsLTk5LjIwMDIgMjYuNjU1OCwtOTkuMjA4OSAyNi43MjQ4LC05OS4yNCAyNi43NDU5LC05OS4yNDI0IDI2Ljc4ODMsLTk5LjI2ODYgMjYuODQzMiwtOTkuMzI4OSAyNi44ODAyLC05OS4zMjE4IDI2LjkwNjgsLTk5LjM4ODMgMjYuOTQ0MiwtOTkuMzc3MyAyNi45NzM4LC05OS40MTU1IDI3LjAxNzIsLTk5LjQ0NjUgMjcuMDIzLC05OS40NTEgMjcuMDY2OCwtOTkuNDMwMyAyNy4wOTQ5LC05OS40Mzk2IDI3LjE1MjEsLTk5LjQyNjQgMjcuMTc4MywtOTkuNDUzOCAyNy4yNjUxLC05OS40OTY2IDI3LjI3MTcsLTk5LjQ5NSAyNy4zMDM5LC05OS41Mzc5IDI3LjMxNzUsLTk5LjUwNDQgMjcuMzM5OSwtOTkuNDgwNCAyNy40ODE2LC05OS41MjgzIDI3LjQ5ODksLTk5LjUxMTEgMjcuNTY0NSwtOTkuNTU2OCAyNy42MTQzLC05OS41OCAyNy42MDIzLC05OS41OTQgMjcuNjM4NiwtOTkuNjM4OSAyNy42MjY4LC05OS42OTEzIDI3LjY2ODcsLTk5LjcyODQgMjcuNjc5MywtOTkuNzcwNyAyNy43MzIxLC05OS44MzMxIDI3Ljc2MjksLTk5Ljg3MjMgMjcuNzk1MywtOTkuODgxMyAyNy44NDk2LC05OS45MDE1IDI3Ljg2NDIsLTk5LjkwMDEgMjcuOTEyMSwtOTkuOTM3MSAyNy45NDA1LC05OS45MzE4IDI3Ljk4MSwtOTkuOTg5OCAyNy45OTI5LC0xMDAuMDE5IDI4LjA2NjQsLTEwMC4wNTYxIDI4LjA5MTMsLTEwMC4wODY5IDI4LjE0NjgsLTEwMC4xNTkyIDI4LjE2NzYsLTEwMC4yMTIyIDI4LjE5NjgsLTEwMC4yMjM2IDI4LjIzNTIsLTEwMC4yNTc4IDI4LjI0MDMsLTEwMC4yOTM1IDI4LjI3ODUsLTEwMC4yODg2IDI4LjMxNywtMTAwLjM0OTMgMjguNDAxNCwtMTAwLjMzNjIgMjguNDMwMiwtMTAwLjM2ODIgMjguNDc4OSwtMTAwLjMzNDcgMjguNTAwMywtMTAwLjM4NyAyOC41MTQsLTEwMC40MTA0IDI4LjU1NDMsLTEwMC4zOTg1IDI4LjU4NTIsLTEwMC40NDc2IDI4LjYxMDEsLTEwMC40NDU3IDI4LjY0MDYsLTEwMC41MDA0IDI4LjY2MiwtMTAwLjUwNzYgMjguNzQwNiwtMTAwLjUzMzYgMjguNzYxMSwtMTAwLjU0NjYgMjguODI0OSwtMTAwLjU3MDUgMjguODI2MywtMTAwLjU5MTUgMjguODg5MywtMTAwLjY0ODggMjguOTQxLC0xMDAuNjQ1OSAyOC45ODY0LC0xMDAuNjY3NSAyOS4wODQzLC0xMDAuNzc1OSAyOS4xNzMzLC0xMDAuNzY1OSAyOS4xODc1LC0xMDAuNzk0OCAyOS4yNDE2LC0xMDAuODc2MSAyOS4yNzk2LC0xMDAuODg2OCAyOS4zMDc4LC0xMDAuOTUwNyAyOS4zNDc3LC0xMDEuMDA2NiAyOS4zNjYsLTEwMS4wNjAyIDI5LjQ1ODcsLTEwMS4xNTE5IDI5LjQ3NywtMTAxLjE3MzggMjkuNTE0NiwtMTAxLjI2MTIgMjkuNTM2OCwtMTAxLjI0MSAyOS41NjUsLTEwMS4yNjIyIDI5LjYzMDYsLTEwMS4yOTEgMjkuNTcxNSwtMTAxLjMxMTYgMjkuNTg1MSwtMTAxLjMgMjkuNjQwNywtMTAxLjMxNDEgMjkuNjU5MSwtMTAxLjM2MzIgMjkuNjUyNiwtMTAxLjM3NTQgMjkuNzAxOCwtMTAxLjQxNTYgMjkuNzQ2NSwtMTAxLjQ0ODkgMjkuNzUwNywtMTAxLjQ1NTggMjkuNzg4LC0xMDEuNTM5MiAyOS43NjE4LC0xMDEuNTQxOSAyOS44MTA4LC0xMDEuNTc1OCAyOS43NjkzLC0xMDEuNzEwNiAyOS43NjE3LC0xMDEuNzYwOSAyOS43ODIxLC0xMDEuODA2MiAyOS43ODA4LC0xMDEuODUzNCAyOS44MDc5LC0xMDEuOTMzNSAyOS43ODUxLC0xMDIuMDM4MyAyOS44MDMxLC0xMDIuMDQ5IDI5Ljc4NTYsLTEwMi4xMTYxIDI5Ljc5MjUsLTEwMi4xOTQ5IDI5LjgzNzEsLTEwMi4zMjA3IDI5Ljg3ODksLTEwMi4zNjQ4IDI5Ljg0NDMsLTEwMi4zODk3IDI5Ljc4MTksLTEwMi41MTc0IDI5Ljc4MzgsLTEwMi41NDggMjkuNzQ1LC0xMDIuNTcyNCAyOS43NTYxLC0xMDIuNjIzIDI5LjczNjQsLTEwMi42NzQ5IDI5Ljc0NDMsLTEwMi42OTM0IDI5LjY3NzIsLTEwMi43NDIyIDI5LjYzMDcsLTEwMi43NDUgMjkuNTkzMiwtMTAyLjc2ODMgMjkuNTk0NywtMTAyLjc3MTQgMjkuNTQ4OSwtMTAyLjgwODQgMjkuNTIyOSwtMTAyLjgzMSAyOS40NDQzLC0xMDIuODI0NyAyOS4zOTczLC0xMDIuODM5OSAyOS4zNjA2LC0xMDIuODc4NiAyOS4zNTM5LC0xMDIuOTAzMiAyOS4yNTQsLTEwMi44NzA2IDI5LjIzNjksLTEwMi44OTAxIDI5LjIwODgsLTEwMi45NTAyIDI5LjE3MzYsLTEwMi45NzM4IDI5LjE4NTUsLTEwMy4wMzI1IDI5LjEwNDcsLTEwMy4wNzUzIDI5LjA5MjMsLTEwMy4xMDA3IDI5LjA2MDIsLTEwMy4xMTUzIDI4Ljk4NTMsLTEwMy4xNTMzIDI4Ljk3MTgsLTEwMy4yMjc0IDI4Ljk5MTUsLTEwMy4yNzkyIDI4Ljk3NzcsLTEwMy4yOTg2IDI5LjAwNjgsLTEwMy40MzM3IDI5LjA0NSwtMTAzLjQ1MDYgMjkuMDcyOCwtMTAzLjU1NDUgMjkuMTU4NSwtMTAzLjcxOTIgMjkuMTgxNCwtMTAzLjc5MjcgMjkuMjYyMywtMTAzLjgxNDcgMjkuMjczOCwtMTAzLjk2OTYgMjkuMjk3OCwtMTA0LjAxOTkgMjkuMzEyMSwtMTA0LjEwNjUgMjkuMzczMSwtMTA0LjE2MyAyOS4zOTE5LC0xMDQuMjE3NSAyOS40NTU5LC0xMDQuMjA5IDI5LjQ4MSwtMTA0LjI2NDIgMjkuNTE0LC0xMDQuMzM4MSAyOS41MiwtMTA0LjQwMDYgMjkuNTczLC0xMDQuNDY2OSAyOS42MDk2LC0xMDQuNTQ0MiAyOS42ODE2LC0xMDQuNTY2MSAyOS43NzE0LC0xMDQuNjI5NSAyOS44NTIzLC0xMDQuNjgyNSAyOS45MzQ4LC0xMDQuNjc0IDI5Ljk1NjcsLTEwNC43MDYzIDMwLjA0OTcsLTEwNC42ODc5IDMwLjA3MzksLTEwNC42OTY2IDMwLjEzNDQsLTEwNC42ODcyIDMwLjE3OSwtMTA0LjcwNjggMzAuMjM1NCwtMTA0Ljc2MzIgMzAuMjc0NCwtMTA0Ljc3MzUgMzAuMzAyNywtMTA0LjgyMjYgMzAuMzUwMywtMTA0LjgxNjMgMzAuMzc0MywtMTA0Ljg1OTUgMzAuMzkxMSwtMTA0Ljg2OTQgMzAuNDc3MywtMTA0Ljg4MjQgMzAuNTMyMywtMTA0LjkxOSAzMC41OTc3LC0xMDQuOTcyMSAzMC42MTAzLC0xMDUuMDA2NSAzMC42ODU4LC0xMDUuMDYyNSAzMC42ODY2LC0xMDUuMTE4MSAzMC43NDk1LC0xMDUuMTYxNyAzMC43NTIxLC0xMDUuMjE3NyAzMC44MDYsLTEwNS4yNTYxIDMwLjc5NDUsLTEwNS4yOTE3IDMwLjgyNjEsLTEwNS4zNjE1IDMwLjg1MDMsLTEwNS4zOTU2IDMwLjg0OSwtMTA1LjQxMzUgMzAuODk5OCwtMTA1LjQ5ODggMzAuOTUwMywtMTA1LjU3ODYgMzEuMDIwNiwtMTA1LjU4NTEgMzEuMDU2OSwtMTA1LjY0NjcgMzEuMTEzOSwtMTA1Ljc3MzkgMzEuMTY4LC0xMDUuODE4OCAzMS4yMzA3LC0xMDUuODc0NyAzMS4yOTEzLC0xMDUuOTMxMiAzMS4zMTI3LC0xMDUuOTUzOSAzMS4zNjQ3LC0xMDYuMDE2MiAzMS4zOTM1LC0xMDYuMDc1MyAzMS4zOTc2LC0xMDYuMTkxMSAzMS40NTk5LC0xMDYuMjE5NiAzMS40ODE2LC0xMDYuMjQ1MiAzMS41MzkxLC0xMDYuMjgwMSAzMS41NjE1LC0xMDYuMzA3OSAzMS42Mjk1LC0xMDYuMzgxMSAzMS43MzIxLC0xMDYuNDUxNCAzMS43NjQ0LC0xMDYuNDkwNSAzMS43NDg5LC0xMDYuNTI4MiAzMS43ODMxLC0xMDYuNTQ3MSAzMS44MDczLC0xMDYuNjA1MyAzMS44Mjc3LC0xMDYuNjQ1NSAzMS44OTg3LC0xMDYuNjExOCAzMS45MiwtMTA2LjYxODUgMzIuMDAwNSwtMTA1Ljk5OCAzMi4wMDIzLC0xMDUuMjUwNSAzMi4wMDAzLC0xMDQuODQ3OCAzMi4wMDA1LC0xMDQuMDI0NSAzMiwtMTAzLjA2NDQgMzIuMDAwNSwtMTAzLjA2NDcgMzIuOTU5MSwtMTAzLjA1NjcgMzMuMzg4NCwtMTAzLjA0NCAzMy45NzQ2LC0xMDMuMDQyNCAzNS4xODMxLC0xMDMuMDQwOCAzNi4wNTUyLC0xMDMuMDQxOSAzNi41MDA0LC0xMDMuMDAyNCAzNi41MDA0LC0xMDIuMDMyMyAzNi41MDA2LC0xMDEuNjIzOSAzNi40OTk1LC0xMDEuMDg1MiAzNi40OTkyLC0xMDAuMDAwNCAzNi40OTk3LC0xMDAuMDAwNCAzNC43NDY1LC05OS45OTc1IDM0LjU2MDYsLTk5LjkyMzIgMzQuNTc0NiwtOTkuODQ0NiAzNC41MDY5LC05OS43NTM0IDM0LjQyMDksLTk5LjY5NDUgMzQuMzc4MiwtOTkuNiAzNC4zNzQ3LC05OS41Nzk4IDM0LjQxNjksLTk5LjUxNzYgMzQuNDE0NSwtOTkuNDMzNSAzNC4zNzAyLC05OS4zOTg3IDM0LjM3NTgsLTk5LjM5NTIgMzQuNDQyLC05OS4zNzU2IDM0LjQ1ODgsLTk5LjMyMDEgMzQuNDA5MywtOTkuMjYxMyAzNC40MDM1LC05OS4yMTA4IDM0LjMzNjgsLTk5LjE4OTggMzQuMjE0NCwtOTkuMDk1MyAzNC4yMTE4LC05OS4wNDM0IDM0LjE5ODIsLTk4Ljk5MTcgMzQuMjIxNCwtOTguOTUyNCAzNC4yMTI1LC05OC44NjAxIDM0LjE0OTksLTk4LjgzMTEgMzQuMTYyMiwtOTguNzY2NyAzNC4xMzY4LC05OC42OTAxIDM0LjEzMzIsLTk4LjY0ODEgMzQuMTY0NCwtOTguNjEwMiAzNC4xNTcxLC05OC41NjAyIDM0LjEzMzIsLTk4LjQ4NyAzNC4wNjI5LC05OC40MjM1IDM0LjA4MjgsLTk4LjM5ODQgMzQuMTI4NSwtOTguMzY0IDM0LjE1NzEsLTk4LjMwMDIgMzQuMTM0NiwtOTguMjMyNSAzNC4xMzQ2LC05OC4xNjg4IDM0LjExNDMsLTk4LjEzOTEgMzQuMTQxOSwtOTguMTAxOSAzNC4xNDY4LC05OC4wOTA1IDM0LjEyMjUsLTk4LjEyMDIgMzQuMDcyMSwtOTguMDgzOCAzNC4wNDE3LC05OC4wODQ0IDM0LjAwMjksLTk4LjAxNjMgMzMuOTk0MSwtOTcuOTc0MiAzNC4wMDY3LC05Ny45NDY4IDMzLjk5MDksLTk3Ljk3MTIgMzMuOTM3MiwtOTcuOTU3MiAzMy45MTQ1LC05Ny45Nzc5IDMzLjg4OTksLTk3Ljg3MTQgMzMuODQ5LC05Ny44MzQzIDMzLjg1NzcsLTk3Ljc2MyAzMy45MzQxLC05Ny43MzIzIDMzLjkzNjcsLTk3LjY4NzcgMzMuOTg3MiwtOTcuNjYxNSAzMy45OTA4LC05Ny41ODg4IDMzLjk1MTksLTk3LjU4OTMgMzMuOTAzOSwtOTcuNTYwOSAzMy44OTk2LC05Ny40ODQyIDMzLjkxNTQsLTk3LjQ1MTEgMzMuODkxNywtOTcuNDYyOSAzMy44NDI5LC05Ny40NDM5IDMzLjgyMzcsLTk3LjM3MjkgMzMuODE5NSwtOTcuMzMxOSAzMy44ODQ1LC05Ny4yNTU2IDMzLjg2MzcsLTk3LjI0NjIgMzMuOTAwMywtOTcuMjEwMyAzMy45MTU5LC05Ny4xODU1IDMzLjkwMDcsLTk3LjE2NjggMzMuODQwNCwtOTcuMTk3NCAzMy44Mjk4LC05Ny4xOTM0IDMzLjc2MDYsLTk3LjE1MTMgMzMuNzIyNiwtOTcuMTExMSAzMy43MTk0LC05Ny4wODg3IDMzLjczODcsLTk3LjA4OCAzMy44MDg3LC05Ny4wNDggMzMuODE3OSwtOTcuMDg3MyAzMy44Mzk4LC05Ny4wNTczIDMzLjg1NjksLTk3LjAyMzUgMzMuODQ0NSwtOTYuOTg1NiAzMy44ODY1LC05Ni45OTYzIDMzLjk0MjcsLTk2LjkzNDggMzMuOTU0NSwtOTYuODk5NCAzMy45MzM3LC05Ni44ODMgMzMuODY4LC05Ni44NTA2IDMzLjg0NzIsLTk2LjgzMjIgMzMuODc0OCwtOTYuNzc5NiAzMy44NTc5LC05Ni43Njk0IDMzLjgyNzUsLTk2LjcxMzcgMzMuODMxMywtOTYuNjkwNyAzMy44NSwtOTYuNjczNCAzMy45MTIzLC05Ni41ODg1IDMzLjg5NSwtOTYuNjI5IDMzLjg1MjQsLTk2LjU3MzIgMzMuODE5MiwtOTYuNTMyOSAzMy44MjMsLTk2LjUwMDcgMzMuNzcyNiwtOTYuNDIyNiAzMy43NzYsLTk2LjM3OTUgMzMuNzI1OCwtOTYuMzYyMiAzMy42OTE4LC05Ni4zMTg0IDMzLjY5NzEsLTk2LjMwMyAzMy43NTA5LC05Ni4yNzczIDMzLjc2OTcsLTk2LjIzMDQgMzMuNzQ4NSwtOTYuMTc4MSAzMy43NjA1LC05Ni4xNDkyIDMzLjgzNzEsLTk2LjEwMTUgMzMuODQ2NywtOTYuMDQ4OCAzMy44MzY1LC05NS45NDE5IDMzLjg2MSwtOTUuOTMyMSAzMy44ODY1LC05NS44NDMzIDMzLjgzODMsLTk1LjgwNDUgMzMuODYyMiwtOTUuNzY3OSAzMy44NDY4LC05NS43NTY2IDMzLjg5MiwtOTUuNjk0OSAzMy44ODY4LC05NS42Njg2IDMzLjkwNywtOTUuNjI3MyAzMy45MDc4LC05NS41OTc1IDMzLjk0MjMsLTk1LjU1NzcgMzMuOTMwNCwtOTUuNTQzNCAzMy44ODA1LC05NS40NTk4IDMzLjg4OCwtOTUuNDM4MiAzMy44NjcxLC05NS4zMTA1IDMzLjg3NzIsLTk1LjI4MjIgMzMuODc1OSwtOTUuMjcxNCAzMy45MTI2LC05NS4yMTk0IDMzLjk2MTYsLTk1LjE1NTkgMzMuOTM2OCwtOTUuMTI5NiAzMy45MzY3LC05NS4xMTc2IDMzLjkwNDYsLTk1LjA4MjQgMzMuODc5OSwtOTUuMDYwMSAzMy45MDE5LC05NS4wNDkgMzMuODY0MSwtOTQuOTY4OSAzMy44NjA5LC05NC45NTM1IDMzLjgxNjUsLTk0LjkyMzMgMzMuODA4NywtOTQuOTExNSAzMy43Nzg0LC05NC44NDkzIDMzLjczOTYsLTk0LjgyMzQgMzMuNzY5MiwtOTQuODAyMyAzMy43MzI4LC05NC43NzEzIDMzLjc2MDcsLTk0Ljc0NjEgMzMuNzAzLC05NC42ODQ4IDMzLjY4NDQsLTk0LjY2NzkgMzMuNjk0NiwtOTQuNjM5MiAzMy42NjM3LC05NC42MjE0IDMzLjY4MjYsLTk0LjU5MDggMzMuNjQ1NiwtOTQuNTQ2NCAzMy42NiwtOTQuNTIwNCAzMy42MTc1LC05NC40ODU5IDMzLjYzNzksLTk0LjM4OTUgMzMuNTQ2NywtOTQuMzUzNiAzMy41NDQsLTk0LjM0NTUgMzMuNTY3MywtOTQuMzA5NiAzMy41NTE3LC05NC4yNzU5IDMzLjU1OCwtOTQuMjE5MiAzMy41NTYxLC05NC4xODQzIDMzLjU5NDYsLTk0LjE0NzQgMzMuNTY1MiwtOTQuMDgyNCAzMy41NzU3LC05NC4wNDM0IDMzLjU1MjMsLTk0LjA0MyAzMy4wMTkyLC05NC4wNDI3IDMxLjk5OTMsLTk0LjAxNTYgMzEuOTc5OSwtOTMuOTcwOCAzMS45MiwtOTMuOTI5OSAzMS45MTI3LC05My44OTY3IDMxLjg4NTMsLTkzLjg3NDggMzEuODIyMywtOTMuODIyNiAzMS43NzM2LC05My44MzY5IDMxLjc1MDIsLTkzLjc5NDUgMzEuNzAyMSwtOTMuODIxNyAzMS42NzQsLTkzLjgxODcgMzEuNjE0NiwtOTMuODM0OSAzMS41ODYyLC05My43ODUgMzEuNTI2LC05My43MTI1IDMxLjUxMzQsLTkzLjc0OTUgMzEuNDY4NywtOTMuNjkyNiAzMS40MzcyLC05My43MDQ5IDMxLjQxMDksLTkzLjY3NDEgMzEuMzk3NywtOTMuNjY5MSAzMS4zNjU0LC05My42ODc1IDMxLjMxMDgsLTkzLjU5ODQgMzEuMjMxMSwtOTMuNjAwMyAzMS4xNzYyLC05My41NTI2IDMxLjE4NTYsLTkzLjUzOTQgMzEuMTE1MiwtOTMuNTYzMiAzMS4wOTcsLTkzLjUyNzYgMzEuMDc0NSwtOTMuNTA4OSAzMS4wMjkzLC05My41NTYzIDMxLjAwNDEsLTkzLjU2ODQgMzAuOTY5MSwtOTMuNTMyMSAzMC45NTc5LC05My41MjYzIDMwLjkyOTcsLTkzLjU1ODYgMzAuOTEzMiwtOTMuNTUzNiAzMC44MzUxLC05My42MTQ4IDMwLjc1NiwtOTMuNjA3NyAzMC43MTU2LC05My42MzE1IDMwLjY3OCwtOTMuNjgzMSAzMC42NDA4LC05My42Nzg4IDMwLjU5ODYsLTkzLjcyNzUgMzAuNTc0NywtOTMuNzMzOCAzMC41MzE3LC05My42OTc4IDMwLjQ0MzgsLTkzLjc0MTcgMzAuNDAyMywtOTMuNzYyMyAzMC4zNTM3LC05My43NDIxIDMwLjMwMSwtOTMuNzA0NyAzMC4yODk5LC05My43MDcgMzAuMjQzNywtOTMuNzIxIDMwLjIxMDQsLTkzLjY5MjggMzAuMTM1MiwtOTMuNzMyOCAzMC4wODI5LC05My43MjI1IDMwLjA1MDksLTkzLjc1NTEgMzAuMDE1MywtOTMuODcxNyAyOS45ODEsLTkzLjg2OTIgMjkuOTM4LC05My45NTA2IDI5Ljg0OTMsLTkzLjk0NjYgMjkuNzgwMSwtOTMuODM3NyAyOS42NzksLTk0LjAxNDMgMjkuNjc5OCwtOTQuMzU0MyAyOS41NjEsLTk0LjQ5OTEgMjkuNTA2OCwtOTQuNDcwMiAyOS41NTcxLC05NC41NDU5IDI5LjU3MjUsLTk0Ljc2MjUgMjkuNTI0MSwtOTQuNzAzOSAyOS42MzI1LC05NC42OTU3IDI5Ljc1NjUsLTk0LjczODkgMjkuNzkwNiwtOTQuODE0MSAyOS43NTksLTk0Ljg3MjggMjkuNjcxNCwtOTQuOTMwMyAyOS42NzM3LC05NS4wMTY2IDI5LjcyMDUsLTk1LjA3MjYgMjkuODI2MiwtOTUuMDk1NSAyOS43NTc2LC05NC45ODMzIDI5LjY4MjMsLTk0Ljk5ODUgMjkuNjE2NCwtOTUuMDc4OSAyOS41MzUzLC05NS4wMTcgMjkuNTQ4LC05NC45MDk2IDI5LjQ5NjEsLTk0Ljk1MDQgMjkuNDY2NywtOTQuODg1NCAyOS4zODk3LC05NS4wNTc0IDI5LjIwMTMsLTk1LjE0OTYgMjkuMTgwNSwtOTUuMjM0MiAyOC45OTI2LC05NS4zODU2IDI4Ljg2NDYsLTk1LjUwNzIgMjguODI1NCwtOTUuNjUzNyAyOC43NDk5LC05NS42NzI3IDI4Ljc0OTUsLTk1Ljc4NCAyOC42Nzk0LC05NS45MTQ5IDI4LjYzODgsLTk1LjY3NzYgMjguNzQ5NCwtOTUuNzg1MyAyOC43NDcxLC05NS45MjM2IDI4LjcwMTUsLTk1Ljk2MDggMjguNjE1MiwtOTYuMzM1NSAyOC40MzgxLC05Ni4xNDYzIDI4LjU0MjcsLTk1Ljk5MDYgMjguNjAxNiwtOTYuMDM4OCAyOC42NTI4LC05Ni4xNTI0IDI4LjYxMzUsLTk2LjIzNTQgMjguNjQyNywtOTYuMjA3OCAyOC42OTgxLC05Ni4zMjI5IDI4LjY0MTksLTk2LjM4NiAyOC42NzQ4LC05Ni40Mjg0IDI4LjcwNzEsLTk2LjQzNDggMjguNjAzLC05Ni41NjE1IDI4LjY0NTQsLTk2LjU3MzYgMjguNzA1NSwtOTYuNjU5NiAyOC43MjI2LC05Ni42NjE0IDI4LjcwMjYsLTk2LjYxMjEgMjguNjM5NCwtOTYuNjM4NSAyOC41NzE5LC05Ni41NjY3IDI4LjU4MjUsLTk2LjQxNTMgMjguNDYzNywtOTYuNDMyMiAyOC40MzI1LC05Ni42NTAzIDI4LjMzMjUsLTk2LjcwODQgMjguNDA3NSwtOTYuNzg1NyAyOC40NDc2LC05Ni43ODMyIDI4LjQwMDQsLTk2Ljg1ODkgMjguNDE3NiwtOTYuNzkwNSAyOC4zMTkyLC05Ni44MDk1IDI4LjIxOTksLTk2LjkxMTEgMjguMTM1NywtOTYuOTg2OCAyOC4xMjg3LC05Ny4wMzczIDI4LjIwMTMsLTk3LjI0MTUgMjguMDYyMywtOTcuMTUgMjguMDMzOCwtOTcuMTM1NCAyOC4wNDcyLC05Ny4wMjQ2IDI4LjExMzMsLTk3LjAzMSAyOC4wNDg2LC05Ny4xMzM4IDI3LjkwMDksLTk3LjE1NjkgMjcuODcyOCwtOTcuMjEzNCAyNy44MjEsLTk3LjI1MDEgMjcuODc2NCwtOTcuMzU0OCAyNy44NTAyLC05Ny4zMzEyIDI3Ljg3MzgsLTk3LjUyODEgMjcuODQ3NCwtOTcuMzgyOSAyNy44Mzg3LC05Ny4zNjE3IDI3LjczNTEsLTk3LjI0NSAyNy42OTMxLC05Ny4zMjQ4IDI3LjU2MSwtOTcuNDEyMyAyNy4zMjI0LC05Ny41MDExIDI3LjI5MTUsLTk3LjQ3MzcgMjcuNDAyOSwtOTcuNTMzOSAyNy4zMzk4LC05Ny42Mzc0IDI3LjMwMSwtOTcuNzM1MiAyNy40MTgyLC05Ny42NjE5IDI3LjI4NzUsLTk3Ljc5NjYgMjcuMjcyNiwtOTcuNjU3NCAyNy4yNzM3LC05Ny41MzQxIDI3LjIyNTMsLTk3LjQ0ODcgMjcuMjYzMSwtOTcuNDUxMSAyNy4xMjE2LC05Ny41MDUyIDI3LjA4NTYsLTk3LjQ3OSAyNi45OTkxLC05Ny41NjE0IDI2Ljk5OCwtOTcuNTYyOSAyNi44Mzg5LC05Ny40NzEgMjYuNzUwMSwtOTcuNDQ2NCAyNi41OTk5LC05Ny40MTc3IDI2LjM3MDIsLTk3LjM0MDYgMjYuMzMxOCwtOTcuMjk1NSAyNi4xOTA4LC05Ny4zMTIxIDI2LjEyMTYsLTk3LjIzNjUgMjYuMDY0NiwtOTcuMjUxNiAyNS45NjQzLC05Ny4xNTI3IDI2LjAyNzUsLTk3LjE0NjMgMjUuOTU1NikpLCgoLTk0LjUxMTcgMjkuNTE1OCwtOTQuNjU5MiAyOS40Mzc1LC05NC43MjgyIDI5LjM3MTYsLTk0Ljc3NzQgMjkuMzc1OSwtOTQuNjg1MiAyOS40NTEzLC05NC41MTE3IDI5LjUxNTgpKSwoKC05NC43NTE4IDI5LjMzMjksLTk0LjgwNDkgMjkuMjc4NywtOTUuMDU2MiAyOS4xMjk5LC05NC44NjEzIDI5LjI5NTMsLTk0Ljc1MTggMjkuMzMyOSkpLCgoLTk2LjgyMDEgMjguMTY0NSwtOTYuNzAzNyAyOC4xOTgsLTk2LjM4NzUgMjguMzc2MiwtOTYuNDQwMyAyOC4zMTg4LC05Ni42ODc4IDI4LjE4NTksLTk2Ljg0NzkgMjguMDY1MSwtOTYuODIwMSAyOC4xNjQ1KSksKCgtOTYuODcyMiAyOC4xMzE1LC05Ni44NSAyOC4wNjM4LC05Ny4wNTU0IDI3Ljg0NzIsLTk2Ljk2MzIgMjguMDIyOSwtOTYuODcyMiAyOC4xMzE1KSksKCgtOTcuMjk0MyAyNi42MDAzLC05Ny4zMjU0IDI2LjYwMDMsLTk3LjMwOTQgMjYuNjI5OCwtOTcuMzkyMSAyNi45MzY3LC05Ny4zOTE2IDI3LjEyNTgsLTk3LjM2NjEgMjcuMjc4MSwtOTcuMzcxMiAyNy4yNzgxLC05Ny4zMzAyIDI3LjQzNTIsLTk3LjI0NzIgMjcuNTgxNSwtOTcuMTk2NCAyNy42ODM3LC05Ny4wOTI1IDI3LjgxMTQsLTk3LjA0NDYgMjcuODM0NCwtOTcuMTUwNCAyNy43MDI3LC05Ny4yMjI3IDI3LjU3NjUsLTk3LjM0NzIgMjcuMjc4LC05Ny4zNzkzIDI3LjA0MDIsLTk3LjM3MDUgMjYuOTA4MSwtOTcuMjkwMSAyNi42MDAzLC05Ny4yOTQzIDI2LjYwMDMpKSkmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4zMS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi05OS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi0wLjE1MTE4MTkyNDU1MzI0NTk0PC92YWx1ZT4KICAgICAgICAgICAgPC90dXBsZT4KICAgICAgICAgIDwvdHVwbGUtcmVmZXJlbmNlPgogICAgICAgIDwvdHVwbGUtc2VsZWN0aW9uPgogICAgICA8L3NlbGVjdGlvbi1jb2xsZWN0aW9uPgogICAgPC93aW5kb3c-CiAgPC93aW5kb3dzPgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Product", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nUHJvZHVjdCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbeXI6T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbbW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoQ2F0ZWdvcnksWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGdyb3VwIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tub25lOkNhdGVnb3J5Om5rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSksUHJvZHVjdCBDYXRlZ29yeSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J01vbnRoJyBuYW1lPSdbbW46T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0Vmlldyc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1Byb2R1Y3REZXRhaWxzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Customers", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ3VzdG9tZXJzJyBzb3VyY2UtYnVpbGQ9JzIwMjQuMi4wICgyMDI0Mi4yNC4wNzE2LjE5NDQpJyB2ZXJzaW9uPScxOC4xJyB4bWxuczp1c2VyPSdodHRwOi8vd3d3LnRhYmxlYXVzb2Z0d2FyZS5jb20veG1sL3VzZXInPgogIDxhY3RpdmUgaWQ9Jy0xJyAvPgogIDxkYXRhc291cmNlcz4KICAgIDxkYXRhc291cmNlIG5hbWU9J2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqJz4KICAgICAgPGNvbHVtbiBkYXRhdHlwZT0nc3RyaW5nJyBuYW1lPSdbOk1lYXN1cmUgTmFtZXNdJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnPgogICAgICAgIDxhbGlhc2VzPgogICAgICAgICAgPGFsaWFzIGtleT0nJnF1b3Q7W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bY3RkOkN1c3RvbWVyIE5hbWU6cWtdJnF1b3Q7JyB2YWx1ZT0nQ291bnQgb2YgQ3VzdG9tZXJzJyAvPgogICAgICAgIDwvYWxpYXNlcz4KICAgICAgPC9jb2x1bW4-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFJlZ2lvbiknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUmVnaW9uKV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUmVnaW9uXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUmVnaW9uKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFJlZ2lvbildJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tTZWdtZW50XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2VnbWVudDpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1F1YXJ0ZXInIG5hbWU9J1txcjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclNjYXR0ZXInPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclJhbmsnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lck92ZXJ2aWV3Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Shipping", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nU2hpcHBpbmcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nLTEnIC8-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChEZWxheWVkPyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoRGVsYXllZD8pJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyxZRUFSKE9yZGVyIERhdGUpLFdFRUsoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW0NhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjNdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3lyOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3R3azpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMsWUVBUihPcmRlciBEYXRlKSxXRUVLKE9yZGVyIERhdGUpKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2FsY3VsYXRpb25fNjQwMTEwMzE3MTI1OTcyM10nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjM6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2hpcCBNb2RlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2hpcCBNb2RlOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nUXVhcnRlcicgbmFtZT0nW3FyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1NoaXBTdW1tYXJ5Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2hpcHBpbmdUcmVuZCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J0RheXN0b1NoaXAnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Performance", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1llYXInIG5hbWU9J1t5cjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgIDwvZGF0YXNvdXJjZT4KICA8L2RhdGFzb3VyY2VzPgogIDx3b3Jrc2hlZXQgbmFtZT0nUGVyZm9ybWFuY2UnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Commission Model", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ29tbWlzc2lvbiBNb2RlbCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMGEwMWNvZDFveGw4M2wxZjV5dmVzMWNmY2lxbyc-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdRdW90YUF0dGFpbm1lbnQnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDb21taXNzaW9uUHJvamVjdGlvbic-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGVzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nT1RFJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Order Details", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3JkZXIgRGV0YWlscycgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbQ2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUG9zdGFsIENvZGVdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpIDEnIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYXRlZ29yeV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhdGVnb3J5Om5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDaXR5XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2l0eTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2VnbWVudF0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlNlZ21lbnQ6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1N0YXRlL1Byb3ZpbmNlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U3RhdGUvUHJvdmluY2U6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0IERldGFpbCBTaGVldCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KPC9jdXN0b21pemVkLXZpZXc-Cg==" + }, + { + "isSourceView": false, + "viewName": "Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J0ZvcmVjYXN0Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "What If Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uIGRhdGF0eXBlPSdzdHJpbmcnIG5hbWU9J1s6TWVhc3VyZSBOYW1lc10nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCc-CiAgICAgICAgPGFsaWFzZXM-CiAgICAgICAgICA8YWxpYXMga2V5PScmcXVvdDtbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltjdGQ6Q3VzdG9tZXIgTmFtZTpxa10mcXVvdDsnIHZhbHVlPSdDb3VudCBvZiBDdXN0b21lcnMnIC8-CiAgICAgICAgPC9hbGlhc2VzPgogICAgICA8L2NvbHVtbj4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6T3JkZXIgRGF0ZTpxa10nIHBpdm90PSdrZXknIHR5cGU9J3F1YW50aXRhdGl2ZScgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tSZWdpb25dJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpSZWdpb246bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1doYXQgSWYgRm9yZWNhc3QnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + } +] \ No newline at end of file From fc849b098a34db7144ae25ad9c3c13cbafad414d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:46:45 -0500 Subject: [PATCH 479/567] docs: add docstrings detailing the filter options --- .../server/endpoint/datasources_endpoint.py | 86 +++++++++++++++++++ .../server/endpoint/flow_runs_endpoint.py | 41 +++++++++ .../server/endpoint/flows_endpoint.py | 38 ++++++++ .../server/endpoint/groups_endpoint.py | 42 +++++++++ .../server/endpoint/groupsets_endpoint.py | 41 +++++++++ .../server/endpoint/jobs_endpoint.py | 57 ++++++++++++ .../server/endpoint/projects_endpoint.py | 44 ++++++++++ .../server/endpoint/users_endpoint.py | 40 +++++++++ .../server/endpoint/views_endpoint.py | 75 ++++++++++++++++ .../server/endpoint/workbooks_endpoint.py | 68 +++++++++++++++ tableauserverclient/server/request_options.py | 1 + 11 files changed, 533 insertions(+) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..471aa380c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -9,6 +9,7 @@ from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: from tableauserverclient.server import Server @@ -459,3 +460,88 @@ def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + authentication_type=... + authentication_type__in=... + connected_workbook_type=... + connected_workbook_type__gt=... + connected_workbook_type__gte=... + connected_workbook_type__lt=... + connected_workbook_type__lte=... + connection_to=... + connection_to__in=... + connection_type=... + connection_type__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + database_name=... + database_name__in=... + database_user_name=... + database_user_name__in=... + description=... + description__in=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alert=... + has_embedded_password=... + has_extracts=... + is_certified=... + is_connectable=... + is_default_port=... + is_hierarchical=... + is_published=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + server_name=... + server_name__in=... + server_port=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + table_name=... + table_name__in=... + tags=... + tags__in=... + type=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 04aefaeee..43ab5153a 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -7,6 +7,7 @@ from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: from tableauserverclient.server.server import Server @@ -78,3 +79,43 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl raise FlowRunCancelledException(flow_run) else: raise AssertionError("Unexpected status in flow_run", flow_run) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowRunItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + complete_at=... + complete_at__gt=... + complete_at__gte=... + complete_at__lt=... + complete_at__lte=... + flow_id=... + flow_id__in=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + user_id=... + user_id__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 858ff91ac..da8aea84b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -22,6 +22,7 @@ get_file_type, get_file_object_size, ) +from tableauserverclient.server.query import QuerySet io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -295,3 +296,40 @@ def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_name=... + project_id=... + project_name=... + project_name__in=... + updated=... + updated__gt=... + updated__gte=... + updated__lt=... + updated__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8c1fe02a7..e0b7f6f1b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -10,6 +10,8 @@ from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from tableauserverclient.server.query import QuerySet + if TYPE_CHECKING: from tableauserverclient.server.request_options import RequestOptions @@ -162,3 +164,43 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] users = UserItem.from_response(server_response.content, self.parent_srv.namespace) logger.info("Added users to group (ID: {0})".format(group_item.id)) return users + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index d24cab52c..a351a8ce8 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.models.groupset_item import GroupSetItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.query import QuerySet from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.server.endpoint.endpoint import api @@ -85,3 +86,43 @@ def update(self, groupset: GroupSetItem) -> GroupSetItem: server_response = self.put_request(url, request) updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return updated_groupset[0] + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupSetItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 74770e22b..2f585e7e4 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,7 @@ import logging +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import JobCancelledException, JobFailedException from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem @@ -74,3 +76,58 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] raise JobCancelledException(job) else: raise AssertionError("Unexpected finish_code in job", job) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[JobItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + args__has=... + completed_at=... + completed_at__gt=... + completed_at__gte=... + completed_at__lt=... + completed_at__lte=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + job_type=... + job_type__in=... + notes__has=... + priority=... + priority__gt=... + priority__gte=... + priority__lt=... + priority__lte=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + status=... + subtitle=... + subtitle__has=... + title=... + title__has=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 259f53b14..03c9535e7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -9,6 +9,8 @@ from typing import List, Optional, Tuple, TYPE_CHECKING +from tableauserverclient.server.query import QuerySet + if TYPE_CHECKING: from tableauserverclient.server.server import Server from tableauserverclient.server.request_options import RequestOptions @@ -154,3 +156,45 @@ def delete_flow_default_permissions(self, item, rule): @api(version="3.4") def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + parent_project_id=... + parent_project_id__in=... + top_level_project=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a84ca7399..0550f5697 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,6 +2,8 @@ import logging from typing import List, Optional, Tuple +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions @@ -166,3 +168,41 @@ def _get_groups_for_user( group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[UserItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + friendly_name=... + friendly_name__in=... + is_local=... + last_login=... + last_login__gt=... + last_login__gte=... + last_login__lt=... + last_login__lte=... + luid=... + luid__in=... + name__cieq=... + name=... + name__in=... + site_role=... + site_role__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f98eb1cd7..7be591bcb 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,8 @@ import logging from contextlib import closing +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError from .permissions_endpoint import _PermissionsEndpoint @@ -173,3 +175,76 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + caption=... + caption__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + fields=... + fields__in=... + hits_total=... + hits_total__gt=... + hits_total__gte=... + hits_total__lt=... + hits_total__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + project_name=... + project_name__in=... + sheet_number=... + sheet_number__gt=... + sheet_number__gte=... + sheet_number__lt=... + sheet_number__lte=... + sheet_type=... + sheet_type__in=... + tags=... + tags__in=... + title=... + title__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + view_url_name=... + view_url_name__in=... + workbook_description=... + workbook_description__in=... + workbook_name=... + workbook_name__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) + diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 30f8ce036..c3652f972 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet from .endpoint import QuerysetEndpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError @@ -498,3 +499,70 @@ def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + content_url=... + content_url__in=... + display_tabs=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alerts=... + has_extracts=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + sheet_count=... + sheet_count__gt=... + sheet_count__gte=... + sheet_count__lt=... + sheet_count__lte=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + subscriptions_total=... + subscriptions_total__gt=... + subscriptions_total__gte=... + subscriptions_total__lt=... + subscriptions_total__lte=... + tags=... + tags__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5cc06bf9d..54ecf6c54 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -38,6 +38,7 @@ class Operator: LessThanOrEqual = "lte" In = "in" Has = "has" + CaseInsensitiveEquals = "cieq" class Field: Args = "args" From 3a47c28bd0a242365d753f64f753092194ec0c35 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 5 Aug 2024 21:51:47 -0500 Subject: [PATCH 480/567] style: black --- tableauserverclient/server/endpoint/datasources_endpoint.py | 1 - tableauserverclient/server/endpoint/flow_runs_endpoint.py | 1 - tableauserverclient/server/endpoint/flows_endpoint.py | 1 - tableauserverclient/server/endpoint/groups_endpoint.py | 1 - tableauserverclient/server/endpoint/groupsets_endpoint.py | 1 - tableauserverclient/server/endpoint/jobs_endpoint.py | 1 - tableauserverclient/server/endpoint/projects_endpoint.py | 1 - tableauserverclient/server/endpoint/users_endpoint.py | 1 - tableauserverclient/server/endpoint/views_endpoint.py | 1 - tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 10 files changed, 1 insertion(+), 10 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 471aa380c..a612adfe0 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -544,4 +544,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 43ab5153a..c339a0645 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -118,4 +118,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index da8aea84b..a2458ad87 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -332,4 +332,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index e0b7f6f1b..8acf31692 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -203,4 +203,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index a351a8ce8..06e7cc627 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -125,4 +125,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 2f585e7e4..a48a3244c 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -130,4 +130,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 03c9535e7..565817e37 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -197,4 +197,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 0550f5697..c4b6418b7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -205,4 +205,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 7be591bcb..f8c50caaf 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -247,4 +247,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index c3652f972..e80fa2daf 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -564,5 +564,5 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe updated_at__lt=... updated_at__lte=... """ - + return super().filter(*invalid, page_size=page_size, **kwargs) From 78291d6ecac2f49b52294342cebb81c34836e0c4 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:26:53 -0500 Subject: [PATCH 481/567] feat: get page and chunk size from env vars --- tableauserverclient/config.py | 18 +++++++++++---- .../server/endpoint/datasources_endpoint.py | 4 ++-- .../server/endpoint/fileuploads_endpoint.py | 4 ++-- tableauserverclient/server/query.py | 3 ++- tableauserverclient/server/request_options.py | 5 ++-- test/test_pager.py | 23 +++++++++++++++++++ 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 1a4a7dc37..4392cac77 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -1,13 +1,23 @@ -# TODO: check for env variables, else set default values +import os ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] BYTES_PER_MB = 1024 * 1024 -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 - DELAY_SLEEP_SECONDS = 0.1 # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 + +class Config: +# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks + @property + def CHUNK_SIZE_MB(self): + return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 + +# Default page size + @property + def PAGE_SIZE(self): + return int(os.getenv("TSC_PAGE_SIZE", 100)) + +config = Config() diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a2..c795b03a3 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -21,7 +21,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -272,7 +272,7 @@ def publish( if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index a0e29e508..0d30797c1 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -2,7 +2,7 @@ from tableauserverclient import datetime_helpers as datetime from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.models import FileuploadItem from tableauserverclient.server import RequestFactory @@ -41,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) + chunked_content = file_content.read(config.CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 195139269..bbca612e9 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import Sized from itertools import count from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions @@ -35,7 +36,7 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions(pagesize=page_size or 100) + self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) self._result_cache: List[T] = [] self._pagination_item = PaginationItem() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5cc06bf9d..563018d1c 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -2,6 +2,7 @@ from typing_extensions import Self +from tableauserverclient.config import config from tableauserverclient.models.property_decorators import property_is_int import logging @@ -115,9 +116,9 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=100): + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber - self.pagesize = pagesize + self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() diff --git a/test/test_pager.py b/test/test_pager.py index b60559b2b..6a0b20b2f 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,9 +1,11 @@ +import contextlib import os import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import config TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +13,15 @@ GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) class PagerTests(unittest.TestCase): def setUp(self): @@ -88,3 +99,15 @@ def test_pager_with_options(self): # Should have the last workbook wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") + + def test_pager_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = TSC.Pager(self.server.workbooks) + assert loop._options.pagesize == 1000 + + def test_queryset_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = self.server.workbooks.all() + assert loop.request_options.pagesize == 1000 From f2710cdc2d70ed00fbad7a634d9d6f2263997595 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 16 Aug 2024 22:32:34 -0500 Subject: [PATCH 482/567] style: black --- tableauserverclient/config.py | 6 ++++-- test/test_pager.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 4392cac77..63872398f 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -9,15 +9,17 @@ # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 + class Config: -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 -# Default page size + # Default page size @property def PAGE_SIZE(self): return int(os.getenv("TSC_PAGE_SIZE", 100)) + config = Config() diff --git a/test/test_pager.py b/test/test_pager.py index 6a0b20b2f..7659f2725 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -13,6 +13,7 @@ GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") + @contextlib.contextmanager def set_env(**environ): old_environ = dict(os.environ) @@ -23,6 +24,7 @@ def set_env(**environ): os.environ.clear() os.environ.update(old_environ) + class PagerTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) @@ -100,13 +102,13 @@ def test_pager_with_options(self): wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_env_var(self) -> None: + def test_pager_with_env_var(self): with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = TSC.Pager(self.server.workbooks) assert loop._options.pagesize == 1000 - def test_queryset_with_env_var(self) -> None: + def test_queryset_with_env_var(self): with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = self.server.workbooks.all() From afc293ec846dcf23eaa1cb46eb293ccb8bf1fd63 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 17 Aug 2024 06:59:32 -0500 Subject: [PATCH 483/567] test: chunk size env var --- test/test_fileuploads.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index cf0861e24..50a5ef48b 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,8 +1,11 @@ +import contextlib +import io import os import unittest import requests_mock +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.server import Server from ._utils import asset @@ -11,6 +14,17 @@ FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + class FileuploadsTests(unittest.TestCase): def setUp(self): self.server = Server("http://test", False) @@ -62,3 +76,14 @@ def test_upload_chunks_file_object(self): actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) + + def test_upload_chunks_config(self): + data = io.BytesIO() + data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1)) + data.seek(0) + with set_env(TSC_CHUNK_SIZE_MB="1"): + chunker = self.server.fileuploads._read_chunks(data) + chunk = next(chunker) + assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB + data.seek(0) + assert len(chunk) < len(data.read()) From d4a2ab697aeb66dcee6b013b0581a5a9986d88e6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:34:22 -0500 Subject: [PATCH 484/567] style: black --- tableauserverclient/server/endpoint/datasources_endpoint.py | 3 +-- tableauserverclient/server/endpoint/views_endpoint.py | 3 +-- tableauserverclient/server/endpoint/workbooks_endpoint.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0a8a404f6..c01e57047 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -460,7 +460,6 @@ def schedule_extract_refresh( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) - @api(version="1.0") def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @@ -472,7 +471,7 @@ def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str @api(version="1.0") def update_tags(self, item: DatasourceItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 4ce394a7a..7a8623614 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -186,7 +186,7 @@ def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str @api(version="1.0") def update_tags(self, item: ViewItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: """ Queries the Tableau Server for items using the specified filters. Page @@ -258,4 +258,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 0e897d4d0..55f61370f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -512,7 +512,7 @@ def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: return super().update_tags(item) - + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: """ Queries the Tableau Server for items using the specified filters. Page @@ -579,4 +579,3 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) - From a3c9afa55ccdcc03a87ad2a2d039067c5cea42ef Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:36:32 -0500 Subject: [PATCH 485/567] chore(typing): flow endpoint tags --- tableauserverclient/server/endpoint/flows_endpoint.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2adbe1f92..1f80e916b 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -297,6 +297,15 @@ def schedule_flow_run( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) + def add_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + def delete_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + def update_tags(self, item: FlowItem) -> None: + return super().update_tags(item) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: """ Queries the Tableau Server for items using the specified filters. Page From 273beb10a97cdb875f3ae7cc9cc00000e4b38e28 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 22 Aug 2024 21:31:41 -0500 Subject: [PATCH 486/567] Revert "chore(typing): flow endpoint tags" This reverts commit a3c9afa55ccdcc03a87ad2a2d039067c5cea42ef. api decorator is masking some problems with mypy, and needs a more in depth investigation than belongs in this branch --- tableauserverclient/server/endpoint/flows_endpoint.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 1f80e916b..2adbe1f92 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -297,15 +297,6 @@ def schedule_flow_run( ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) - def add_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> Set[str]: - return super().add_tags(item, tags) - - def delete_tags(self, item: Union[FlowItem, str], tags: Union[Iterable[str], str]) -> None: - return super().delete_tags(item, tags) - - def update_tags(self, item: FlowItem) -> None: - return super().update_tags(item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: """ Queries the Tableau Server for items using the specified filters. Page From ffa0601901203e0c3cb90e45748bb1a896603cf0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 23 Aug 2024 10:34:47 -0500 Subject: [PATCH 487/567] chore(typing): endpoint decorators --- .../server/endpoint/datasources_endpoint.py | 4 +- .../server/endpoint/endpoint.py | 43 +++++++++++++------ .../server/endpoint/jobs_endpoint.py | 21 ++++++--- .../server/endpoint/workbooks_endpoint.py | 2 +- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 77d898d86..caa591fac 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -126,7 +126,7 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( datasource_id, None, @@ -405,7 +405,7 @@ def _get_datasource_revisions( def download_revision( self, datasource_id: str, - revision_number: str, + revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 6b29e736a..3ebebe28b 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,30 +1,42 @@ +from typing_extensions import Concatenate from tableauserverclient import datetime_helpers as datetime import abc from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + TYPE_CHECKING, + ParamSpec, + Tuple, + TypeVar, + Union, +) from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions -from .exceptions import ( +from tableauserverclient.server.endpoint.exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, NotSignedInError, ) -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import DELAY_SLEEP_SECONDS if TYPE_CHECKING: - from ..server import Server + from tableauserverclient.server.server import Server from requests import Response @@ -38,7 +50,7 @@ USER_AGENT_HEADER = "User-Agent" -class Endpoint(object): +class Endpoint: def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @@ -232,7 +244,12 @@ def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, paramet ) -def api(version): +E = TypeVar("E", bound="Endpoint") +P = ParamSpec("P") +R = TypeVar("R") + + +def api(version: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. @@ -251,9 +268,9 @@ def api(version): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) @@ -262,7 +279,7 @@ def wrapper(self, *args, **kwargs): return _decorator -def parameter_added_in(**params): +def parameter_added_in(**params: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates @@ -285,9 +302,9 @@ def parameter_added_in(**params): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: import warnings server_ver = Version(self.parent_srv.version or "0.0") @@ -335,5 +352,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index a48a3244c..54e699722 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing_extensions import Self, overload from tableauserverclient.server.query import QuerySet @@ -13,15 +14,25 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint[JobItem]): +class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @overload # type: ignore[override] + def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + @api(version="2.6") - def get( - self, job_id: Optional[str] = None, req_options: Optional[RequestOptionsBase] = None - ) -> Tuple[List[BackgroundJobItem], PaginationItem]: + def get(self, job_id=None, req_options=None): # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -77,7 +88,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] else: raise AssertionError("Unexpected finish_code in job", job) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[JobItem]: + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[BackgroundJobItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 55f61370f..78a3b0858 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -184,7 +184,7 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( workbook_id, None, From 8d6b3f24830eb9212c839ce0783d07e81bb0a8f3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 23 Aug 2024 11:21:07 -0500 Subject: [PATCH 488/567] chore(typing): request factory ts_wrapped --- tableauserverclient/server/request_factory.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index aeb355ea6..04a1138a3 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,8 +1,9 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, TYPE_CHECKING +from typing import Any, Callable, Dict, Iterable, List, Optional, ParamSpec, Set, Tuple, TypeVar, TYPE_CHECKING, Union from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from typing_extensions import Concatenate from tableauserverclient.models import * @@ -23,8 +24,12 @@ def _add_multipart(parts: Dict) -> Tuple[Any, str]: return xml_request, content_type -def _tsrequest_wrapped(func): - def wrapper(self, *args, **kwargs) -> bytes: +T = TypeVar("T") +P = ParamSpec("P") + + +def _tsrequest_wrapped(func: Callable[Concatenate[T, ET.Element, P], Any]) -> Callable[Concatenate[T, P], bytes]: + def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> bytes: xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -388,7 +393,7 @@ def add_user_req(self, user_id: str) -> bytes: return ET.tostring(xml_request) @_tsrequest_wrapped - def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -399,7 +404,7 @@ def add_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> b return ET.tostring(xml_request) @_tsrequest_wrapped - def remove_users_req(self, xml_request, users: Iterable[Union[str, UserItem]]) -> bytes: + def remove_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -1055,14 +1060,17 @@ def publish_req_chunked( return _add_multipart(parts) @_tsrequest_wrapped - def embedded_extract_req(self, xml_request, include_all=True, datasources=None): + def embedded_extract_req( + self, xml_request: ET.Element, include_all: bool = True, datasources: Optional[Iterable[DatasourceItem]] = None + ) -> None: list_element = ET.SubElement(xml_request, "datasources") if include_all: list_element.attrib["includeAll"] = "true" elif datasources: for datasource_item in datasources: datasource_element = ET.SubElement(list_element, "datasource") - datasource_element.attrib["id"] = datasource_item.id + if (id_ := datasource_item.id) is not None: + datasource_element.attrib["id"] = id_ class Connection(object): @@ -1090,7 +1098,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") class TaskRequest(object): @_tsrequest_wrapped - def run_req(self, xml_request, task_item): + def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass @@ -1227,7 +1235,7 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt class EmptyRequest(object): @_tsrequest_wrapped - def empty_req(self, xml_request): + def empty_req(self, xml_request: ET.Element) -> None: pass From 9f7bfaaf55054e47c0c75521cb1a335426cdaf62 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 07:31:30 -0500 Subject: [PATCH 489/567] chore: fix typing on TaggingMixin --- .../server/endpoint/datasources_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 2 +- .../server/endpoint/resource_tagger.py | 39 ++++++++++--------- .../server/endpoint/tables_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index caa591fac..7f3a47075 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -55,7 +55,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2adbe1f92..53d072f50 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -51,7 +51,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint[FlowItem], TaggingMixin): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index f6b1cab05..1894e3b8a 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Iterable, Optional, Protocol, Set, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -62,27 +62,24 @@ def update_tags(self, baseurl, resource_item): logger.info("Updated tags to {0}".format(resource_item.tags)) -class HasID(Protocol): - @property - def id(self) -> Optional[str]: - pass +class Response(Protocol): + content: bytes @runtime_checkable class Taggable(Protocol): - _initial_tags: Set[str] tags: Set[str] + _initial_tags: Set[str] @property def id(self) -> Optional[str]: pass -class Response(Protocol): - content: bytes +T = TypeVar("T") -class TaggingMixin(abc.ABC): +class TaggingMixin(abc.ABC, Generic[T]): parent_srv: "Server" @property @@ -98,7 +95,7 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -114,7 +111,7 @@ def add_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) - def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -130,17 +127,23 @@ def delete_tags(self, item: Union[HasID, Taggable, str], tags: Union[Iterable[st url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" self.delete_request(url) - def update_tags(self, item: Taggable) -> None: - if item.tags == item._initial_tags: + def update_tags(self, item: T) -> None: + if (initial_tags := getattr(item, "_initial_tags", None)) is None: + raise ValueError(f"{item} does not have initial tags.") + if (tags := getattr(item, "tags", None)) is None: + raise ValueError(f"{item} does not have tags.") + if tags == initial_tags: return - add_set = item.tags - item._initial_tags - remove_set = item._initial_tags - item.tags + add_set = tags - initial_tags + remove_set = initial_tags - tags self.delete_tags(item, remove_set) if add_set: - item.tags = self.add_tags(item, add_set) - item._initial_tags = copy.copy(item.tags) - logger.info(f"Updated tags to {item.tags}") + tags = self.add_tags(item, add_set) + setattr(item, "tags", tags) + + setattr(item, "_initial_tags", copy.copy(tags)) + logger.info(f"Updated tags to {tags}") content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b2e41df8b..36ef78c0a 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -13,7 +13,7 @@ from tableauserverclient.helpers.logging import logger -class Tables(Endpoint, TaggingMixin): +class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 7a8623614..f2ccf658e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -23,7 +23,7 @@ ) -class Views(QuerysetEndpoint[ViewItem], TaggingMixin): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 78a3b0858..da6eda3de 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -59,7 +59,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) From 01b171efcb6841d4c0e7e7940016bd6d953fe412 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:19:07 -0500 Subject: [PATCH 490/567] test: update_tags --- test/test_tagging.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index d3f23d40e..7621ce6ed 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack import re from typing import Iterable import uuid @@ -6,7 +7,6 @@ import pytest import requests_mock import tableauserverclient as TSC -from tableauserverclient.server.endpoint.resource_tagger import content @pytest.fixture @@ -153,6 +153,42 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: urls = {r.url.split("/")[-1] for r in history} assert urls == tag_set +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) +def test_update_tags(get_server, endpoint_type, item, tags) -> None: + if isinstance(item, str): + return + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + tags = set([tags] if isinstance(tags, str) else tags) + with ExitStack() as stack: + if hasattr(item, "_initial_tags"): + initial_tags = set(['x','y','z']) + item._initial_tags = initial_tags + add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) + delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) + m = stack.enter_context(requests_mock.mock()) + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + + tag_paths = "|".join(initial_tags - tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + m.delete( + matcher, + status_code=200, + text=delete_tags_xml, + ) + + else: + stack.enter_context(pytest.raises(NotImplementedError)) + + + endpoint.update_tags(item) + def test_tags_batch_add(get_server) -> None: server = get_server From 99d330f7bbb8079b8de65d665015d815e470612d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:20:00 -0500 Subject: [PATCH 491/567] fix: ParamSpec introduced in 3.10 --- tableauserverclient/server/endpoint/endpoint.py | 3 +-- tableauserverclient/server/request_factory.py | 4 +++- test/test_tagging.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 3ebebe28b..0e55d5739 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,4 +1,4 @@ -from typing_extensions import Concatenate +from typing_extensions import Concatenate, ParamSpec from tableauserverclient import datetime_helpers as datetime import abc @@ -13,7 +13,6 @@ List, Optional, TYPE_CHECKING, - ParamSpec, Tuple, TypeVar, Union, diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 04a1138a3..7fc9c9555 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,7 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, ParamSpec, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union + +from typing_extensions import ParamSpec from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata diff --git a/test/test_tagging.py b/test/test_tagging.py index 7621ce6ed..2ec2b8112 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -153,6 +153,7 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: urls = {r.url.split("/")[-1] for r in history} assert urls == tag_set + @pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) @pytest.mark.parametrize("tags", sample_tags) def test_update_tags(get_server, endpoint_type, item, tags) -> None: @@ -163,7 +164,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: tags = set([tags] if isinstance(tags, str) else tags) with ExitStack() as stack: if hasattr(item, "_initial_tags"): - initial_tags = set(['x','y','z']) + initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) @@ -186,7 +187,6 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: else: stack.enter_context(pytest.raises(NotImplementedError)) - endpoint.update_tags(item) From cc4e47a55208d37c9dadd30dfc8cf2afb4f6582e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 24 Aug 2024 12:45:21 -0500 Subject: [PATCH 492/567] test: update_tags item doesn't have tags attributes --- test/test_tagging.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_tagging.py b/test/test_tagging.py index 2ec2b8112..fc88eea8a 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -157,13 +157,13 @@ def test_delete_tags(get_server, endpoint_type, item, tags) -> None: @pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) @pytest.mark.parametrize("tags", sample_tags) def test_update_tags(get_server, endpoint_type, item, tags) -> None: - if isinstance(item, str): - return endpoint = getattr(get_server, endpoint_type) id_ = getattr(item, "id", item) tags = set([tags] if isinstance(tags, str) else tags) with ExitStack() as stack: - if hasattr(item, "_initial_tags"): + if isinstance(item, str): + stack.enter_context(pytest.raises((ValueError, NotImplementedError))) + elif hasattr(item, "_initial_tags"): initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) From 51112f1e6d91fea450882a86457af3016a3f3ec2 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:11:06 -0500 Subject: [PATCH 493/567] fix: str/repr for BackgroundJobItem --- tableauserverclient/models/job_item.py | 2 +- test/test_job.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 9933d7f29..12d025bef 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -222,7 +222,7 @@ def __init__( self._subtitle = subtitle def __str__(self): - return f"<{self.__class__.name} {self._id} {self._type}>" + return f"<{self.__class__.__qualname__} {self._id} {self._type}>" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" diff --git a/test/test_job.py b/test/test_job.py index 8e248882c..d86397086 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -136,3 +136,11 @@ def test_get_job_datasource_name(self) -> None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.datasource_name, "World Indicators") + + def test_background_job_str(self) -> None: + job = TSC.BackgroundJobItem( + "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed" + ) + assert not str(job).startswith("< Date: Thu, 11 Jul 2024 21:24:23 -0500 Subject: [PATCH 494/567] chore: jobs_endpoint absolute imports --- tableauserverclient/server/endpoint/jobs_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index a48a3244c..b9ac24924 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,11 +1,11 @@ import logging -from tableauserverclient.server.query import QuerySet -from .endpoint import QuerysetEndpoint, api -from .exceptions import JobCancelledException, JobFailedException from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem -from ..request_options import RequestOptionsBase +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException +from tableauserverclient.server.query import QuerySet +from tableauserverclient.server.request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger From 3c3c5b30d8c6b89793bd3a5c69fd67c16c4ebeb0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:34:32 -0500 Subject: [PATCH 495/567] chore: absolute imports for jobitem --- tableauserverclient/models/job_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 12d025bef..155ce668b 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -4,7 +4,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .flow_run_item import FlowRunItem +from tableauserverclient.models.flow_run_item import FlowRunItem class JobItem(object): From 7dc5ad4ae36a1609a8f2374aafe06b2598fa9926 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:32:56 -0500 Subject: [PATCH 496/567] fix: Pager typing Pager Protocols were missing the generic flags. Added those in so the Pager correctly passes through the typing information. Also removes the kwargs from the function signature of the Endpoint.get protocol to make the Workbook endpoint match. Adding a return annotation of `None` to the tests is very important because it is what enables static type checkers, like mypy, to inspect those functions. With these annotations now in place, users of TSC should more transparently be able to carry through typing information when using the Pager. --- tableauserverclient/server/pager.py | 14 +++++++------- test/test_pager.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index fede56012..cb6dd2d75 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,24 +1,24 @@ +from collections.abc import Iterable, Iterator import copy from functools import partial -from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions T = TypeVar("T") -ReturnType = Tuple[List[T], PaginationItem] @runtime_checkable -class Endpoint(Protocol): - def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class Endpoint(Protocol[T]): + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: ... @runtime_checkable -class CallableEndpoint(Protocol): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class CallableEndpoint(Protocol[T]): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: ... @@ -33,7 +33,7 @@ class Pager(Iterable[T]): def __init__( self, - endpoint: Union[CallableEndpoint, Endpoint], + endpoint: Union[CallableEndpoint[T], Endpoint[T]], request_opts: Optional[RequestOptions] = None, **kwargs, ) -> None: diff --git a/test/test_pager.py b/test/test_pager.py index 7659f2725..e548ace2b 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -9,6 +9,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +GET_VIEW_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") @@ -35,7 +36,7 @@ def setUp(self): self.baseurl = self.server.workbooks.baseurl - def test_pager_with_no_options(self): + def test_pager_with_no_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -61,7 +62,7 @@ def test_pager_with_no_options(self): self.assertEqual(wb2.name, "Page2Workbook") self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_options(self): + def test_pager_with_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -102,14 +103,22 @@ def test_pager_with_options(self): wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_env_var(self): + def test_pager_with_env_var(self) -> None: with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = TSC.Pager(self.server.workbooks) assert loop._options.pagesize == 1000 - def test_queryset_with_env_var(self): + def test_queryset_with_env_var(self) -> None: with set_env(TSC_PAGE_SIZE="1000"): assert config.PAGE_SIZE == 1000 loop = self.server.workbooks.all() assert loop.request_options.pagesize == 1000 + + def test_pager_view(self) -> None: + with open(GET_VIEW_XML, "rb") as f: + view_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl, text=view_xml) + for view in TSC.Pager(self.server.views): + assert view.name == "Test View" From a7b5e2c07bff436ea7ca00d63063569fab9a1bdd Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:41:18 -0500 Subject: [PATCH 497/567] fix: python3.8 syntax --- tableauserverclient/server/pager.py | 3 +-- test/test_pager.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index cb6dd2d75..ca9d83872 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ -from collections.abc import Iterable, Iterator import copy from functools import partial -from typing import List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions diff --git a/test/test_pager.py b/test/test_pager.py index e548ace2b..c30352809 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -119,6 +119,6 @@ def test_pager_view(self) -> None: with open(GET_VIEW_XML, "rb") as f: view_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(self.baseurl, text=view_xml) + m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): - assert view.name == "Test View" + assert view.name is not None From fad98bd4fca42d22965d855abb8ae0454b9e0a80 Mon Sep 17 00:00:00 2001 From: "jac.fitzgerald" Date: Mon, 2 Sep 2024 12:31:33 -0700 Subject: [PATCH 498/567] feat: virtual connections Merge pull request #1429 from jorwoods/jorwoods/virtual_connections --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/connection_item.py | 6 +- tableauserverclient/models/tableau_types.py | 16 +- .../models/virtual_connection_item.py | 77 ++++++ .../server/endpoint/__init__.py | 2 + .../endpoint/virtual_connections_endpoint.py | 173 +++++++++++++ tableauserverclient/server/request_factory.py | 60 +++++ tableauserverclient/server/server.py | 2 + .../virtual_connection_add_permissions.xml | 21 ++ ..._connection_database_connection_update.xml | 6 + ...irtual_connection_populate_connections.xml | 6 + test/assets/virtual_connections_download.xml | 7 + test/assets/virtual_connections_get.xml | 14 + test/assets/virtual_connections_publish.xml | 7 + test/assets/virtual_connections_revisions.xml | 14 + test/assets/virtual_connections_update.xml | 8 + test/test_tagging.py | 8 + test/test_virtual_connection.py | 242 ++++++++++++++++++ 19 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 tableauserverclient/models/virtual_connection_item.py create mode 100644 tableauserverclient/server/endpoint/virtual_connections_endpoint.py create mode 100644 test/assets/virtual_connection_add_permissions.xml create mode 100644 test/assets/virtual_connection_database_connection_update.xml create mode 100644 test/assets/virtual_connection_populate_connections.xml create mode 100644 test/assets/virtual_connections_download.xml create mode 100644 test/assets/virtual_connections_get.xml create mode 100644 test/assets/virtual_connections_publish.xml create mode 100644 test/assets/virtual_connections_revisions.xml create mode 100644 test/assets/virtual_connections_update.xml create mode 100644 test/test_virtual_connection.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index f8549992f..bab2cf05f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -43,6 +43,7 @@ TaskItem, UserItem, ViewItem, + VirtualConnectionItem, WebhookItem, WeeklyInterval, WorkbookItem, @@ -124,4 +125,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "VirtualConnectionItem", ] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 41676da2c..e4131b720 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -45,6 +45,7 @@ from tableauserverclient.models.task_item import TaskItem from tableauserverclient.models.user_item import UserItem from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem @@ -96,6 +97,7 @@ "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WorkbookItem", "LinkedTaskItem", diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 29ffd2700..62ff530c9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -66,12 +66,14 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: for connection_xml in all_connection_xml: connection_item = cls() connection_item._id = connection_xml.get("id", None) - connection_item._connection_type = connection_xml.get("type", None) + connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) connection_item.username = connection_xml.get("userName", None) - connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None)) + connection_item._query_tagging = ( + string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None + ) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 33fe5eb0c..bac072076 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,11 +1,12 @@ from typing import Union -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from .metric_item import MetricItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem class Resource: @@ -18,12 +19,13 @@ class Resource: Metric = "metric" Project = "project" View = "view" + VirtualConnection = "virtualConnection" Workbook = "workbook" # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem] +TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] def plural_type(content_type: Resource) -> str: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py new file mode 100644 index 000000000..76a3b5dea --- /dev/null +++ b/tableauserverclient/models/virtual_connection_item.py @@ -0,0 +1,77 @@ +import datetime as dt +import json +from typing import Callable, Dict, Iterable, List, Optional +from xml.etree.ElementTree import Element + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule + + +class VirtualConnectionItem: + def __init__(self, name: str) -> None: + self.name = name + self.created_at: Optional[dt.datetime] = None + self.has_extracts: Optional[bool] = None + self._id: Optional[str] = None + self.is_certified: Optional[bool] = None + self.updated_at: Optional[dt.datetime] = None + self.webpage_url: Optional[str] = None + self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None + self.project_id: Optional[str] = None + self.owner_id: Optional[str] = None + self.content: Optional[Dict[str, dict]] = None + self.certification_note: Optional[str] = None + + def __str__(self) -> str: + return f"{self.__class__.__qualname__}(name={self.name})" + + def __repr__(self) -> str: + return f"<{self!s}>" + + def _set_permissions(self, permissions): + self._permissions = permissions + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def permissions(self) -> List[PermissionsRule]: + if self._permissions is None: + error = "Workbook item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def connections(self) -> Iterable[ConnectionItem]: + if self._connections is None: + raise AttributeError("connections not populated. Call populate_connections() first.") + return self._connections() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + parsed_response = fromstring(response) + return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] + + @classmethod + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + v_conn = cls(xml.get("name", "")) + v_conn._id = xml.get("id", None) + v_conn.webpage_url = xml.get("webpageUrl", None) + v_conn.created_at = parse_datetime(xml.get("createdAt", None)) + v_conn.updated_at = parse_datetime(xml.get("updatedAt", None)) + v_conn.is_certified = string_to_bool(s) if (s := xml.get("isCertified", None)) else None + v_conn.certification_note = xml.get("certificationNote", None) + v_conn.has_extracts = string_to_bool(s) if (s := xml.get("hasExtracts", None)) else None + v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None + v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None + v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None + return v_conn + + +def string_to_bool(s: str) -> bool: + return s.lower() in ["true", "1", "t", "y", "yes"] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 30eae9224..b05b9addd 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -27,6 +27,7 @@ from tableauserverclient.server.endpoint.tasks_endpoint import Tasks from tableauserverclient.server.endpoint.users_endpoint import Users from tableauserverclient.server.endpoint.views_endpoint import Views +from tableauserverclient.server.endpoint.virtual_connections_endpoint import VirtualConnections from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks @@ -62,6 +63,7 @@ "Tasks", "Users", "Views", + "VirtualConnections", "Webhooks", "Workbooks", ] diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py new file mode 100644 index 000000000..f71db00cc --- /dev/null +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -0,0 +1,173 @@ +from functools import partial +import json +from pathlib import Path +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union + +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.pager import Pager + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" + + @api(version="3.18") + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + return virtual_connections, pagination_item + + @api(version="3.18") + def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + def _connection_fetcher(): + return Pager(partial(self._get_virtual_database_connections, virtual_connection)) + + virtual_connection._connections = _connection_fetcher + return virtual_connection + + def _get_virtual_database_connections( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[ConnectionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + + return connections, pagination_item + + @api(version="3.18") + def update_connection_db_connection( + self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem + ) -> ConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify" + xml_request = RequestFactory.VirtualConnection.update_db_connection(connection) + server_response = self.put_request(url, xml_request) + return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}" + server_response = self.get_request(url) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str: + v_conn = self.get_by_id(virtual_connection) + return json.dumps(v_conn.content) + + @api(version="3.23") + def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + url = f"{self.baseurl}/{virtual_connection.id}" + xml_request = RequestFactory.VirtualConnection.update(virtual_connection) + server_response = self.put_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_revisions( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[RevisionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) + return revisions, pagination_item + + @api(version="3.23") + def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str: + url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}" + server_response = self.get_request(url) + virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return json.dumps(virtual_connection.content) + + @api(version="3.23") + def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + self.delete_request(f"{self.baseurl}/{vconn_id}") + + @api(version="3.23") + def publish( + self, + virtual_connection: VirtualConnectionItem, + virtual_connection_content: str, + mode: str = "CreateNew", + publish_as_draft: bool = False, + ) -> VirtualConnectionItem: + """ + Publish a virtual connection to the server. + + For the virtual_connection object, name, project_id, and owner_id are + required. + + The virtual_connection_content can be a json string or a file path to a + json file. + + The mode can be "CreateNew" or "Overwrite". If mode is + "Overwrite" and the virtual connection already exists, it will be + overwritten. + + If publish_as_draft is True, the virtual connection will be published + as a draft, and the id of the draft will be on the response object. + """ + try: + json.loads(virtual_connection_content) + except json.JSONDecodeError: + file = Path(virtual_connection_content) + if not file.exists(): + raise RuntimeError(f"{virtual_connection_content} is not valid json nor an existing file path") + content = file.read_text() + else: + content = virtual_connection_content + + if mode not in ["CreateNew", "Overwrite"]: + raise ValueError(f"Invalid mode: {mode}") + overwrite = mode == "Overwrite" + + url = f"{self.baseurl}?overwrite={str(overwrite).lower()}&publishAsDraft={str(publish_as_draft).lower()}" + xml_request = RequestFactory.VirtualConnection.publish(virtual_connection, content) + server_response = self.post_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.22") + def populate_permissions(self, item: VirtualConnectionItem) -> None: + self._permissions.populate(item) + + @api(version="3.22") + def add_permissions(self, resource, rules): + return self._permissions.update(resource, rules) + + @api(version="3.22") + def delete_permission(self, item, capability_item): + return self._permissions.delete(item, capability_item) + + @api(version="3.23") + def add_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> Set[str]: + return super().add_tags(virtual_connection, tags) + + @api(version="3.23") + def delete_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> None: + return super().delete_tags(virtual_connection, tags) + + @api(version="3.23") + def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: + raise NotImplementedError("Update tags is not implemented for Virtual Connections") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 7fc9c9555..96fa14680 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1356,6 +1356,65 @@ def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem" return ET.tostring(xml_request) +class VirtualConnectionRequest: + @_tsrequest_wrapped + def update_db_connection(self, xml_request: ET.Element, connection_item: ConnectionItem) -> bytes: + connection_element = ET.SubElement(xml_request, "connection") + if connection_item.server_address is not None: + connection_element.attrib["serverAddress"] = connection_item.server_address + if connection_item.server_port is not None: + connection_element.attrib["serverPort"] = str(connection_item.server_port) + if connection_item.username is not None: + connection_element.attrib["userName"] = connection_item.username + if connection_item.password is not None: + connection_element.attrib["password"] = connection_item.password + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + if virtual_connection.is_certified is not None: + vc_element.attrib["isCertified"] = str(virtual_connection.is_certified).lower() + if virtual_connection.certification_note is not None: + vc_element.attrib["certificationNote"] = virtual_connection.certification_note + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem, content: str) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + else: + raise ValueError("Virtual Connection must have a name.") + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + else: + raise ValueError("Virtual Connection must have a project id.") + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + else: + raise ValueError("Virtual Connection must have an owner id.") + if content is not None: + content_element = ET.SubElement(vc_element, "content") + content_element.text = content + else: + raise ValueError("Virtual Connection must have content.") + + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -1382,5 +1441,6 @@ class RequestFactory(object): Tag = TagRequest() Task = TaskRequest() User = UserRequest() + VirtualConnection = VirtualConnectionRequest() Workbook = WorkbookRequest() Webhook = WebhookRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 20a7dc3df..e563a7138 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -36,6 +36,7 @@ LinkedTasks, GroupSets, Tags, + VirtualConnections, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -105,6 +106,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.linked_tasks = LinkedTasks(self) self.group_sets = GroupSets(self) self.tags = Tags(self) + self.virtual_connections = VirtualConnections(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/virtual_connection_add_permissions.xml b/test/assets/virtual_connection_add_permissions.xml new file mode 100644 index 000000000..d8b052848 --- /dev/null +++ b/test/assets/virtual_connection_add_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connection_database_connection_update.xml b/test/assets/virtual_connection_database_connection_update.xml new file mode 100644 index 000000000..a6135d604 --- /dev/null +++ b/test/assets/virtual_connection_database_connection_update.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/virtual_connection_populate_connections.xml b/test/assets/virtual_connection_populate_connections.xml new file mode 100644 index 000000000..77d899520 --- /dev/null +++ b/test/assets/virtual_connection_populate_connections.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/assets/virtual_connections_download.xml b/test/assets/virtual_connections_download.xml new file mode 100644 index 000000000..889e70ce7 --- /dev/null +++ b/test/assets/virtual_connections_download.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_get.xml b/test/assets/virtual_connections_get.xml new file mode 100644 index 000000000..f1f410e4c --- /dev/null +++ b/test/assets/virtual_connections_get.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/test/assets/virtual_connections_publish.xml b/test/assets/virtual_connections_publish.xml new file mode 100644 index 000000000..889e70ce7 --- /dev/null +++ b/test/assets/virtual_connections_publish.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_revisions.xml b/test/assets/virtual_connections_revisions.xml new file mode 100644 index 000000000..374113427 --- /dev/null +++ b/test/assets/virtual_connections_revisions.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connections_update.xml b/test/assets/virtual_connections_update.xml new file mode 100644 index 000000000..60d5d1697 --- /dev/null +++ b/test/assets/virtual_connections_update.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/test/test_tagging.py b/test/test_tagging.py index fc88eea8a..0184af415 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -83,6 +83,12 @@ def make_flow() -> TSC.FlowItem: return flow +def make_vconn() -> TSC.VirtualConnectionItem: + vconn = TSC.VirtualConnectionItem("test") + vconn._id = str(uuid.uuid4()) + return vconn + + sample_taggable_items = ( [ ("workbooks", make_workbook()), @@ -97,6 +103,8 @@ def make_flow() -> TSC.FlowItem: ("databases", "some_id"), ("flows", make_flow()), ("flows", "some_id"), + ("virtual_connections", make_vconn()), + ("virtual_connections", "some_id"), ], ) diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py new file mode 100644 index 000000000..975033d2d --- /dev/null +++ b/test/test_virtual_connection.py @@ -0,0 +1,242 @@ +import json +from pathlib import Path +import unittest + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem + +ASSET_DIR = Path(__file__).parent / "assets" + +VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" +VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" +VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" +VIRTUAL_CONNECTION_REVISIONS = ASSET_DIR / "virtual_connections_revisions.xml" +VIRTUAL_CONNECTION_PUBLISH = ASSET_DIR / "virtual_connections_publish.xml" +ADD_PERMISSIONS = ASSET_DIR / "virtual_connection_add_permissions.xml" + + +class TestVirtualConnections(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test") + + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.23" + + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/virtualConnections" + return super().setUp() + + def test_from_xml(self): + items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), self.server.namespace) + + assert len(items) == 1 + virtual_connection = items[0] + assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z") + assert not virtual_connection.has_extracts + assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + assert virtual_connection.is_certified + assert virtual_connection.name == "vconn" + assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") + assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" + + def test_virtual_connection_get(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text()) + items, pagination_item = self.server.virtual_connections.get() + + assert len(items) == 1 + assert pagination_item.total_available == 1 + assert items[0].name == "vconn" + + def test_virtual_connection_populate_connections(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" + + def test_virtual_connection_update_connection_db_connection(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + connection = TSC.ConnectionItem() + connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + connection.server_address = "localhost" + connection.server_port = "5432" + connection.username = "pgadmin" + connection.password = "password" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}/connections/{connection.id}/modify", text=VC_DB_CONN_UPDATE.read_text()) + updated_connection = self.server.virtual_connections.update_connection_db_connection(vconn, connection) + + assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert updated_connection.server_address == "localhost" + assert updated_connection.server_port == "5432" + assert updated_connection.username == "pgadmin" + assert updated_connection.password is None + + def test_virtual_connection_get_by_id(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + vconn = self.server.virtual_connections.get_by_id(vconn) + + assert vconn.content + assert vconn.created_at is None + assert vconn.id is None + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_update(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.is_certified = True + vconn.certification_note = "demo certification note" + vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b" + vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text()) + vconn = self.server.virtual_connections.update(vconn) + + assert not vconn.has_extracts + assert vconn.id is None + assert vconn.is_certified + assert vconn.name == "testv1" + assert vconn.certification_note == "demo certification note" + assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b" + assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + + def test_virtual_connection_get_revisions(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text()) + revisions, pagination_item = self.server.virtual_connections.get_revisions(vconn) + + assert len(revisions) == 3 + assert pagination_item.total_available == 3 + assert revisions[0].resource_id == vconn.id + assert revisions[0].resource_name == vconn.name + assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z") + assert revisions[0].revision_number == "1" + assert not revisions[0].current + assert not revisions[0].deleted + assert revisions[0].user_name == "Cassie" + assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + assert revisions[1].resource_id == vconn.id + assert revisions[1].resource_name == vconn.name + assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z") + assert revisions[1].revision_number == "2" + assert not revisions[1].current + assert not revisions[1].deleted + assert revisions[2].resource_id == vconn.id + assert revisions[2].resource_name == vconn.name + assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z") + assert revisions[2].revision_number == "3" + assert revisions[2].current + assert not revisions[2].deleted + assert revisions[2].user_name == "Cassie" + assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + + def test_virtual_connection_download_revision(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + content = self.server.virtual_connections.download_revision(vconn, 1) + + assert content + assert "policyCollection" in content + data = json.loads(content) + assert "policyCollection" in data + assert "revision" in data + + def test_virtual_connection_delete(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{vconn.id}") + self.server.virtual_connections.delete(vconn) + self.server.virtual_connections.delete(vconn.id) + + assert m.call_count == 2 + + def test_virtual_connection_publish(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=false&publishAsDraft=false", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_publish_draft_overwrite(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=true&publishAsDraft=true", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_add_permissions(self) -> None: + with open(ADD_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") + + single_virtual_connection = TSC.VirtualConnectionItem("test") + single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace" + + bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + TSC.PermissionsRule(bob, {"Write": "Allow"}), + TSC.PermissionsRule(group_of_people, {"Read": "Deny"}), + ] + + with requests_mock.mock() as m: + m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = self.server.virtual_connections.add_permissions(single_virtual_connection, new_permissions) + + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) From 60e6b9c14128a541d843d3687f2c08c81b1ec326 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 5 Sep 2024 22:44:58 -0500 Subject: [PATCH 499/567] feat: add as_reference method to groupset --- tableauserverclient/models/groupset_item.py | 5 +++++ tableauserverclient/models/permissions_item.py | 11 +++++++---- test/assets/flow_populate_permissions.xml | 9 ++++++++- test/test_flow.py | 10 ++++++++++ test/test_groupsets.py | 9 +++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index 879e8b02d..ffb57adf5 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -4,6 +4,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.reference_item import ResourceReference class GroupSetItem: @@ -46,3 +47,7 @@ def get_group(group_xml: ET.Element) -> GroupItem: ] return group_set_item + + @staticmethod + def as_reference(id_: str) -> ResourceReference: + return ResourceReference(id_, GroupSetItem.tag_name) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index fecdb9723..26f4ee7e8 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -3,10 +3,11 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError -from .group_item import GroupItem -from .reference_item import ResourceReference -from .user_item import UserItem +from tableauserverclient.models.exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.reference_item import ResourceReference +from tableauserverclient.models.user_item import UserItem from tableauserverclient.helpers.logging import logger @@ -142,6 +143,8 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict grantee = UserItem.as_reference(grantee_id) elif grantee_type == "group": grantee = GroupItem.as_reference(grantee_id) + elif grantee_type == "groupSet": + grantee = GroupSetItem.as_reference(grantee_id) else: raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml index 59fe5bd67..ce3a22f97 100644 --- a/test/assets/flow_populate_permissions.xml +++ b/test/assets/flow_populate_permissions.xml @@ -11,5 +11,12 @@ + + + + + + + - \ No newline at end of file + diff --git a/test/test_flow.py b/test/test_flow.py index a90b18171..d458bc77b 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -142,6 +142,16 @@ def test_populate_permissions(self) -> None: }, ) + self.assertEqual(permissions[1].grantee.tag_name, "groupSet") + self.assertEqual(permissions[1].grantee.id, "7ea95a1b-6872-44d6-a969-68598a7df4a0") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + def test_publish(self) -> None: with open(PUBLISH_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_groupsets.py b/test/test_groupsets.py index d3c9085a4..5479809d2 100644 --- a/test/test_groupsets.py +++ b/test/test_groupsets.py @@ -5,6 +5,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference TEST_ASSET_DIR = Path(__file__).parent / "assets" GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" @@ -128,3 +129,11 @@ def test_remove_group(self) -> None: assert len(history) == 1 assert history[0].method == "DELETE" assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_as_reference(self) -> None: + groupset = TSC.GroupSetItem() + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + ref = groupset.as_reference(groupset.id) + assert ref.id == groupset.id + assert ref.tag_name == groupset.tag_name + assert isinstance(ref, ResourceReference) From 28c24875625e9b3d50a29a6d1d10162fb7d06a19 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 17 Sep 2024 13:24:04 -0700 Subject: [PATCH 500/567] Add support for all types of monthly repeating schedules (#1462) Previously we just handled monthly schedules which repeated on a day (1-31) or 'LastDay'. Tableau Server has since added more options such as "first Monday". This change catches up the interval validation to match what might be received from the server. Fixes #1358 * Add failing test for "monthly on first Monday" schedule * Add support for all monthly schedule variations * Unrelated fix for debug logging of API responses and add a small warning --- tableauserverclient/models/interval_item.py | 41 ++++++++++++------- .../server/endpoint/endpoint.py | 4 +- test/assets/schedule_get_monthly_id_2.xml | 12 ++++++ test/test_schedule.py | 16 ++++++++ 4 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 test/assets/schedule_get_monthly_id_2.xml diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 3ee1fee08..444674e19 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -246,21 +246,34 @@ def interval(self): @interval.setter def interval(self, interval_values): - # This is weird because the value could be a str or an int - # The only valid str is 'LastDay' so we check that first. If that's not it - # try to convert it to an int, if that fails because it's an incorrect string - # like 'badstring' we catch and re-raise. Otherwise we convert to int and check - # that it's in range 1-31 + # Valid monthly intervals strings can contain any of the following + # day numbers (1-31) (integer or string) + # relative day within the month (First, Second, ... Last) + # week days (Sunday, Monday, ... LastDay) + VALID_INTERVALS = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "LastDay", + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Last", + ] + for value in range(1, 32): + VALID_INTERVALS.append(str(value)) + VALID_INTERVALS.append(value) + for interval_value in interval_values: - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value not in VALID_INTERVALS: + error = f"Invalid monthly interval: {interval_value}" + raise ValueError(error) self._interval = interval_values diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 0e55d5739..be0602df5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -144,7 +144,9 @@ def _make_request( loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}".format(url)) - # logger.debug("\n\t{1}".format(loggable_response)) + # uncomment the following to log full responses in debug mode + # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA + # logger.debug(loggable_response) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) diff --git a/test/assets/schedule_get_monthly_id_2.xml b/test/assets/schedule_get_monthly_id_2.xml new file mode 100644 index 000000000..ca84297e7 --- /dev/null +++ b/test/assets/schedule_get_monthly_id_2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 3bbf5709b..0377295d7 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -14,6 +14,7 @@ GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") +GET_MONTHLY_ID_2_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id_2.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -158,6 +159,21 @@ def test_get_monthly_by_id(self) -> None: self.assertEqual("Active", schedule.state) self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_get_monthly_by_id_2(self) -> None: + self.server.version = "3.15" + with open(GET_MONTHLY_ID_2_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly First Monday!", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", "First"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) From 4584717be8a71565720af55cd3265eb2b158344a Mon Sep 17 00:00:00 2001 From: "jac.fitzgerald" Date: Tue, 17 Sep 2024 13:43:47 -0700 Subject: [PATCH 501/567] README update to kick CLA bot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51da7bda0..5c80f337e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code for the library and sample files showing how to use it. As of May 2022, Python versions 3.7 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. As of September 2024, support for Python 3.7 and 3.8 will be dropped - support for older versions of Python aims to match https://devguide.python.org/versions/ To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. From 60a3a2fef9880df686b8dd374677b54f47687faa Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 18 Sep 2024 23:46:43 -0700 Subject: [PATCH 502/567] Draft: Make urllib3 dependency more flexible (#1468) Make urllib3 dependency more flexible Per discussion in #1445 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bf47ea23..b0428ec08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.2.2', # dependabot + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" From ac8dccd321e669114b7665c1afa4759031801778 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 19 Sep 2024 01:47:15 -0500 Subject: [PATCH 503/567] chore(versions): Upgrade minimum python version (#1465) * chore(versions): Upgrade minimum python version As of October, 2024, Python 3.8 is out of support. Upgrading syntax to target Python 3.9. Adds builds for Python 3.13. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/run-tests.yml | 2 +- pyproject.toml | 10 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +-- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 17 +-- samples/explore_favorites.py | 10 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +++--- samples/export.py | 6 +- samples/extracts.py | 2 +- samples/filter_sort_groups.py | 2 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 ++-- samples/initialize_server.py | 10 +- samples/list.py | 2 +- samples/login.py | 4 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 4 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/update_workbook_data_acceleration.py | 2 +- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/_version.py | 18 +-- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 10 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 ++-- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 8 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +-- tableauserverclient/models/job_item.py | 16 +-- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 20 ++-- tableauserverclient/models/project_item.py | 10 +- .../models/property_decorators.py | 23 ++-- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 6 +- tableauserverclient/models/site_item.py | 6 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 10 +- tableauserverclient/models/tableau_types.py | 2 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 34 +++--- tableauserverclient/models/view_item.py | 21 ++-- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 24 ++-- tableauserverclient/namespace.py | 2 +- .../server/endpoint/auth_endpoint.py | 16 +-- .../server/endpoint/custom_views_endpoint.py | 24 ++-- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 ++--- .../server/endpoint/databases_endpoint.py | 25 ++-- .../server/endpoint/datasources_endpoint.py | 97 ++++++++------- .../endpoint/default_permissions_endpoint.py | 27 +++-- .../server/endpoint/dqw_endpoint.py | 18 +-- .../server/endpoint/endpoint.py | 35 +++--- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 62 +++++----- .../server/endpoint/fileuploads_endpoint.py | 20 ++-- .../server/endpoint/flow_runs_endpoint.py | 18 +-- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +++++----- .../server/endpoint/groups_endpoint.py | 35 +++--- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +-- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 ++-- .../server/endpoint/permissions_endpoint.py | 28 +++-- .../server/endpoint/projects_endpoint.py | 20 ++-- .../server/endpoint/resource_tagger.py | 27 ++--- .../server/endpoint/schedules_endpoint.py | 32 ++--- .../server/endpoint/server_info_endpoint.py | 4 +- .../server/endpoint/sites_endpoint.py | 34 +++--- .../server/endpoint/subscriptions_endpoint.py | 20 ++-- .../server/endpoint/tables_endpoint.py | 29 ++--- .../server/endpoint/tasks_endpoint.py | 16 +-- .../server/endpoint/users_endpoint.py | 38 +++--- .../server/endpoint/views_endpoint.py | 37 +++--- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 ++-- .../server/endpoint/workbooks_endpoint.py | 110 ++++++++---------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 19 ++- tableauserverclient/server/request_factory.py | 73 ++++++------ tableauserverclient/server/request_options.py | 12 +- tableauserverclient/server/server.py | 18 +-- tableauserverclient/server/sort.py | 4 +- test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +-- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 6 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_project.py | 36 +++--- test/test_regression_tests.py | 6 +- test/test_request_option.py | 14 +-- test/test_schedule.py | 16 +-- test/test_site_model.py | 2 - test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 ++++---- 136 files changed, 948 insertions(+), 990 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d70539582..7e1533eef 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] runs-on: ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index b0428ec08..cc3bf8fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,26 +18,26 @@ dependencies = [ 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] [tool.mypy] check_untyped_defs = false diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 5a450e8ab..d26d009e2 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index f4c6a9ca9..aca3e895b 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,7 +11,6 @@ import os from datetime import time -from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -63,23 +62,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print("Add users to site from file {}:".format(filepath)) - added: List[TSC.UserItem] - failed: List[TSC.UserItem, TSC.ServerResponseError] + print(f"Add users to site from file {filepath}:") + added: list[TSC.UserItem] + failed: list[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print("Adding users to group:{}".format(added)) + print(f"Adding users to group:{added}") for user in added: - print("Adding user {}".format(user)) + print(f"Adding user {user}") try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print("user {} is already a member of group {}".format(user.name, group.name)) + print(f"user {user.name} is already a member of group {group.name}") else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index 1fc649f8c..d775902aa 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print("Permissions from project {}:".format(changed_project.id)) + print(f"Permissions from project {changed_project.id}:") print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index dee088571..c23a2eced 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + print(f"Hourly schedule created (ID: {hourly_schedule.id}).") except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + print(f"Daily schedule created (ID: {daily_schedule.id}).") except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + print(f"Weekly schedule created (ID: {weekly_schedule.id}).") except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + print(f"Monthly schedule created (ID: {monthly_schedule.id}).") except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index fb45cb45e..877c5f08d 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -54,13 +54,13 @@ def main(): new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print("Datasource published. ID: {}".format(new_datasource.id)) + print(f"Datasource published. ID: {new_datasource.id}") else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} datasources on site: ") print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -69,20 +69,15 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print("\nConnections for {}: ".format(sample_datasource.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections - ] - ) + print(f"\nConnections for {sample_datasource.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print("\nOld tag set: {}".format(original_tag_set)) - print("New tag set: {}".format(sample_datasource.tags)) + print(f"\nOld tag set: {original_tag_set}") + print(f"New tag set: {sample_datasource.tags}") # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 243e91954..364e078cc 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -39,7 +39,7 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print("Favorites for user: {}".format(user.id)) + print(f"Favorites for user: {user.id}") server.favorites.get(user) print(user.favorites) @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,12 +70,10 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print( - "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) - ) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") server.favorites.delete_favorite_view(user, my_view) - print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index a2274f1a7..eb9eba0de 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print("Delete site `{}`?".format(current_site.name)) + print(f"Delete site `{current_site.name}`?") # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 77802b1db..f25c41849 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print("Webhook created. ID: {}".format(new_webhook.id)) + print(f"Webhook created. ID: {new_webhook.id}") # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} webhooks on site: ") print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 57f88aa07..f51639ab3 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print("Workbook published. ID: {}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,27 +78,22 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print("\nName of views in {}: ".format(sample_workbook.name)) + print(f"\nName of views in {sample_workbook.name}: ") print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print("\nConnections for {}: ".format(sample_workbook.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections - ] - ) + print(f"\nConnections for {sample_workbook.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print("\nWorkbook's old tag set: {}".format(original_tag_set)) - print("Workbook's new tag set: {}".format(sample_workbook.tags)) - print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) + print(f"\nWorkbook's old tag set: {original_tag_set}") + print(f"Workbook's new tag set: {sample_workbook.tags}") + print(f"Workbook tabbed: {sample_workbook.show_tabs}") # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -109,8 +104,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print("\nView's old tag set: {}".format(original_tag_set)) - print("View's new tag set: {}".format(sample_view.tags)) + print(f"\nView's old tag set: {original_tag_set}") + print(f"View's new tag set: {sample_view.tags}") # Delete tag from just one view sample_view.tags = original_tag_set @@ -119,14 +114,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print("\nDownloaded workbook to {}".format(path)) + print(f"\nDownloaded workbook to {path}") if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") # get custom views cvs, _ = server.custom_views.get() @@ -153,10 +148,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print("deleting {}".format(c.id)) + print(f"deleting {c.id}") unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index f2783fa6e..815ec8b51 100644 --- a/samples/export.py +++ b/samples/export.py @@ -60,10 +60,10 @@ def main(): item = server.views.get_by_id(args.resource_id) if not item: - print("No item found for id {}".format(args.resource_id)) + print(f"No item found for id {args.resource_id}") exit(1) - print("Item found: {}".format(item.name)) + print(f"Item found: {item.name}") # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -83,7 +83,7 @@ def main(): if args.file: filename = args.file else: - filename = "out.{}".format(extension) + filename = f"out.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index 9bd87a473..d21bfdd0b 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -47,7 +47,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 042af32e2..d967659ad 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -71,7 +71,7 @@ def main(): group_name = filtered_groups.pop().name print(group_name) else: - error = "No project named '{}' found".format(filter_group_name) + error = f"No project named '{filter_group_name}' found" print(error) # Or, try the above with the django style filtering diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c1..6c3a85dcd 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = "No project named '{}' found".format(filter_project_name) + error = f"No project named '{filter_project_name}' found" print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 454b225de..5f8cfa238 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print("Connected to {}".format(server.server_info.baseurl)) - print("Server information: {}".format(server.server_info)) + print(f"Connected to {server.server_info.baseurl}") + print(f"Server information: {server.server_info}") print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index d62896059..8635947a8 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in the url # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 21de97831..a2c4301d0 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in a url # e.g https://my-server/#/this-is-your-site-url-name/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print("{} workbooks".format(pagination.total_available)) + print(f"{pagination.total_available} workbooks") print(workbooks[0]) views, pagination = server.views.get() if views: - print("{} views".format(pagination.total_available)) + print(f"{pagination.total_available} views") print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print("{} datasources".format(pagination.total_available)) + print(f"{pagination.total_available} datasources") print(datasources[0]) # I think all these other content types can go to a hello_universe script # data alert, dqw, flow, ... do any of these require any add-ons? jobs, pagination = server.jobs.get() if jobs: - print("{} jobs".format(pagination.total_available)) + print(f"{pagination.total_available} jobs") print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print("{} schedules".format(pagination.total_available)) + print(f"{pagination.total_available} schedules") print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print("{} tasks".format(pagination.total_available)) + print(f"{pagination.total_available} tasks") print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print("{} webhooks".format(pagination.total_available)) + print(f"{pagination.total_available} webhooks") print(webhooks[0]) users, pagination = server.users.get() if users: - print("{} users".format(pagination.total_available)) + print(f"{pagination.total_available} users") print(users[0]) groups, pagination = server.groups.get() if groups: - print("{} groups".format(pagination.total_available)) + print(f"{pagination.total_available} groups") print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cb3d9e1d0..cdfaf27a8 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...".format(args.site_id)) + print(f"Site not found: {args.site_id} Creating it...") new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...".format(args.site_id)) + print(f"Site {args.site_id} exists. Moving on...") ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...".format(args.project)) + print(f"Project not found: {args.project} Creating it...") new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print("Datasource published. ID: {0}".format(new_ds.id)) + print(f"Datasource published. ID: {new_ds.id}") def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 8d72fb620..11e664695 100644 --- a/samples/list.py +++ b/samples/list.py @@ -59,7 +59,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print("Total: {}".format(count)) + print(f"Total: {count}") if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index 6a3e9e8b3..847d3558f 100644 --- a/samples/login.py +++ b/samples/login.py @@ -59,7 +59,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") else: # Trying to authenticate using personal access tokens. @@ -68,7 +68,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f9..e82c75cf9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print("No workbook named {} found.".format(args.workbook_name)) + print(f"No workbook named {args.workbook_name} found.") else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + True for site in all_sites if args.destination_site.lower() == site.content_url.lower() ) if not found_destination_site: - error = "No site named {} found.".format(args.destination_site) + error = f"No site named {args.destination_site} found." raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a7ae6dc89..a68eed4b3 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Total: {}\n".format(count)) + print(f"Total: {count}\n") count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Truncated Total: {}\n".format(count)) + print(f"Truncated Total: {count}\n") print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print("Filtered Total: {}\n".format(count)) + print(f"Filtered Total: {count}\n") # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print("QuerySet Total: {}".format(count)) + print(f"QuerySet Total: {count}") # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 5ac768674..85f63fb35 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -111,14 +111,14 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + print(f"Datasource published asynchronously. Job ID: {new_job.id}") else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{0}Datasource published. Datasource ID: {1}".format( + "{}Datasource published. Datasource ID: {}".format( new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 8a9f45279..d31978c0f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. JOB ID: {0}".format(new_job.id)) + print(f"Workbook published. JOB ID: {new_job.id}") else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 4e509cd97..3309acd90 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,17 +57,15 @@ def main(): permissions = resource.permissions # Print result - print( - "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) - ) + print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 03daedf16..c95000898 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print("{}".format(task)) + print(f"{task}") def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print("{}".format(task)) + print(f"{task}") def main(): diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py index 75f12262f..57a1363ed 100644 --- a/samples/update_workbook_data_acceleration.py +++ b/samples/update_workbook_data_acceleration.py @@ -43,7 +43,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9e4d63dc1..c23e3717f 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index d47374097..5d1dca9df 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print("unable to find command, tried {}".format(commands)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print("Tried directories {} but none started with prefix {}".format(str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( full_tag, tag_prefix, ) diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index df936e315..3a7416e28 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem(object): +class ColumnItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d61bbb751..bb2cbbba9 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials(object): +class ConnectionCredentials: """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 62ff530c9..937e43481 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem(object): +class ConnectionItem: def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> List["ConnectionItem"]: + def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ - all_connection_items: List["ConnectionItem"] = list() + all_connection_items: list["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 246a19e7f..de917bf4a 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,7 +11,7 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem(object): +class CustomViewItem: def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None @@ -35,7 +35,7 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return "".format(self.id, self.name, view_info, wb_info, owner_info) + return f"" def _set_image(self, image): self._image = image @@ -104,7 +104,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -121,7 +121,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 7424e6b95..3a8883bed 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem(object): - class ComparisonRecord(object): +class DataAccelerationReportItem: + class ComparisonRecord: def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 65be233e3..7285ee609 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem(object): +class DataAlertItem: class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[List[str]] = None + self._recipients: Optional[list[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> List[str]: + def recipients(self) -> list[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> List["DataAlertItem"]: + def from_response(cls, resp, ns) -> list["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index f567c501c..6e0cb9001 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional, Union, List +from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[List[str]] = interval_item + self.interval_item: Optional[list[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[List[str]]: + def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: List[str]): + def interval_item(self, value: list[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + error = f"Invalid interval value for a monthly frequency: {interval_values}." # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] # First check if the list only have LastDay value. When using LastDay, there shouldn't be diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index dfc58e1bb..4d4604461 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem(object): +class DatabaseItem: class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return "".format(self._id, self.name) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e4e71c4a2..1b082c157 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem(object): +class DatasourceItem: class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: Set = set() + self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List[ConnectionItem]]: + def connections(self) -> Optional[list[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List[PermissionsRule]]: + def permissions(self) -> Optional[list[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: + def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index ada041481..fbda9d9f2 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem(object): +class DQWItem: class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index caff755e3..f157283cb 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -9,20 +9,18 @@ from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem -from typing import Dict, List from tableauserverclient.helpers.logging import logger -from typing import Dict, List, Union -FavoriteType = Dict[ +FavoriteType = dict[ str, - List[TableauItem], + list[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + def from_response(cls, xml: str, namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b2..aea4dfe1f 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem(object): +class FileuploadItem: def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index edce2ec97..9bcad5e89 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, Set +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem(object): +class FlowItem: def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> List["FlowItem"]: + def from_response(cls, resp, ns) -> list["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 12281f4f8..f2f1d561f 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Dict, List, Optional, Type +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem(object): +class FlowRunItem: def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6c8f7eb01..6871f8b16 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TYPE_CHECKING +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem(object): +class GroupItem: tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return "{}({!r})".format(self.__class__.__name__, self.__dict__) + return f"{self.__class__.__name__}({self.__dict__!r})" @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> List["GroupItem"]: + def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ffb57adf5..aa653a79e 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: List["GroupItem"] = [] + self.groups: list["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 444674e19..d7cf891cc 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem(object): +class IntervalItem: class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval(object): +class HourlyInterval: def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval(object): +class DailyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval(object): +class WeeklyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval(object): +class MonthlyInterval: def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 155ce668b..cc7cd5811 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem(object): +class JobItem: class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[List[str]] = None, + notes: Optional[list[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: List[str] = notes or [] + self._notes: list[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> List[str]: + def notes(self) -> list[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> List["JobItem"]: + def from_response(cls, xml, ns) -> list["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem(object): +class BackgroundJobItem: class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index ae9b60425..14a0e4978 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: List[LinkedTaskFlowRunItem] = [] + self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index d8ba8e825..432fd861a 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Set +from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem(object): +class MetricItem: def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: Set[str] = set() - self.tags: Set[str] = set() + self._initial_tags: set[str] = set() + self.tags: set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> List["MetricItem"]: + ) -> list["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 8cebd1c86..f30519be5 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem(object): +class PaginationItem: def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 26f4ee7e8..3e4fec22a 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Dict, List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -47,12 +47,12 @@ def __repr__(self): class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return "".format(self.grantee, self.capabilities) + return f"" def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -66,7 +66,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -86,7 +86,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): @@ -100,14 +100,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: Dict[str, str] = {} + capability_dict: dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -116,7 +116,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: {}".format(capability_xml)) + logger.error(f"Capability was not valid: {capability_xml}") raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -127,7 +127,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -146,6 +146,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9fb382885..d875abbdf 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,14 +8,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -158,7 +158,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, @@ -166,7 +166,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> List["ProjectItem"]: + def from_response(cls, resp, ns) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ce31b1428..5048b3498 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,8 @@ import datetime import re from functools import wraps -from typing import Any, Container, Optional, Tuple +from typing import Any, Optional +from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -11,7 +12,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) + error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." raise ValueError(error) return func(self, value) @@ -24,7 +25,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.__name__) + error = f"Boolean expected for {func.__name__} flag." raise ValueError(error) return func(self, value) @@ -35,7 +36,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.__name__) + error = f"{func.__name__} must be defined." raise ValueError(error) return func(self, value) @@ -46,7 +47,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must not be empty.".format(func.__name__) + error = f"{func.__name__} must not be empty." raise ValueError(error) return func(self, value) @@ -66,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -81,7 +82,7 @@ def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid property defined: '{}'. Integer value expected.".format(value) + error = f"Invalid property defined: '{value}'. Integer value expected." if range is None: if isinstance(value, int): @@ -133,7 +134,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" ) dt = parse_datetime(value) @@ -146,11 +147,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = "{} should have 2 keys ".format(func.__name__) + error = f"{func.__name__} should have 2 keys " error += "'acceleration_enabled' and 'accelerate_now'" - error += "instead you have {}".format(value.keys()) + error += f"instead you have {value.keys()}" raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 710548fcc..4c1fff564 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference(object): +class ResourceReference: def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return "".format(self._id, self._tag_name) + return f"" __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a0e6a1bd5..1b4cc6249 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem(object): +class RevisionItem: def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e416643ba..e39042058 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem(object): +class ScheduleItem: class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - "Status {}: {}".format(response.status_code, response.reason) + f"Status {response.status_code}: {response.reason}" if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 57fc51af9..5c3f6acc7 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +class ServerInfoItem: def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,11 +40,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(f"Unexpected response for ServerInfo: {resp}") logger.info(error) return cls("Unknown", "Unknown", "Unknown") diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index b651e5773..2d9f014a2 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,13 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem(object): +class SiteItem: _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -873,7 +873,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> List["SiteItem"]: + def from_response(cls, resp, ns) -> list["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e96fcc448..61c75e2d6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import List, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem(object): +class SubscriptionItem: def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index f9df8a8f3..0afdd4df3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem(object): +class TableItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 10cf58723..c1e9d62bf 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Dict, Optional +from typing import Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: credentials = ( "Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request" @@ -42,7 +42,7 @@ def __init__( self.username = username @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -69,7 +69,7 @@ def __init__( self.personal_access_token = personal_access_token @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -95,7 +95,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index bac072076..ea2a5e4f8 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -32,4 +32,4 @@ def plural_type(content_type: Resource) -> str: if content_type == Resource.Lens: return "lenses" else: - return "{}s".format(content_type) + return f"{content_type}s" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index afa0a0762..cde755f05 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,16 +1,15 @@ import xml.etree.ElementTree as ET -from typing import Set from defusedxml.ElementTree import fromstring -class TagItem(object): +class TagItem: @classmethod - def from_response(cls, resp: bytes, ns) -> Set[str]: + def from_response(cls, resp: bytes, ns) -> set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 01cfcfb11..fa6f782ba 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem(object): +class TaskItem: class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fe659575a..fb29492e4 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -18,7 +18,7 @@ from tableauserverclient.server import Pager -class UserItem(object): +class UserItem: tag_name: str = "user" class Roles: @@ -57,7 +57,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[Dict[str, List]] = None + self._favorites: Optional[dict[str, list]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -69,7 +69,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -141,7 +141,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> Dict[str, List]: + def favorites(self) -> dict[str, list]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -210,12 +210,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> List["UserItem"]: + def from_response(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -283,7 +283,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport(object): + class CSVImport: """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -308,7 +308,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: List[str] = list(map(str.strip, line.split(","))) + values: list[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -337,7 +337,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -345,11 +345,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L while line and line != "": try: # do not print passwords - logger.info("Reading user {}".format(line[:4])) + logger.info(f"Reading user {line[:4]}") UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info("Error parsing {}: {}".format(line[:4], exc)) + logger.info(f"Error parsing {line[:4]}: {exc}") invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -358,7 +358,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: List[List[str]] = [ + _valid_attributes: list[list[str]] = [ [], [], [], @@ -373,23 +373,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug("> details - {}".format(username)) + logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}".format(item, column_type)) + raise AttributeError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a26e364a3..dc5f37a48 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,8 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Iterator, List, Optional, Set +from typing import Callable, Optional +from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -11,13 +12,13 @@ from .tag_item import TagItem -class ViewItem(object): +class ViewItem: def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -29,15 +30,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None - self.tags: Set[str] = set() + self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None + self.tags: set[str] = set() self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -146,21 +147,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 76a3b5dea..e9e22be1e 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,7 @@ import datetime as dt import json -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterable from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -23,7 +24,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[Dict[str, dict]] = None + self.content: Optional[dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -40,7 +41,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -53,12 +54,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index e4d5e4aa0..98d821fb4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import List, Optional, Tuple, Type +from typing import Optional from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem(object): +class WebhookItem: def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = "webhook-source-event-{}".format(value) + self._event = f"webhook-source-event-{value}" @classmethod - def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: + def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return "".format(self.id, self.name, self.url, self.event) + return f"" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 58fd2a9a9..ab5ff4157 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Optional from defusedxml.ElementTree import fromstring @@ -20,7 +20,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +class WorkbookItem: def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -35,15 +35,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], List[ViewItem]]] = None + self._views: Optional[Callable[[], list[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[List[str]] = None - self.tags: Set[str] = set() + self.hidden_views: Optional[list[str]] = None + self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -56,7 +56,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -64,14 +64,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> List[ConnectionItem]: + def connections(self) -> list[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -152,7 +152,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> List[ViewItem]: + def views(self) -> list[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -191,7 +191,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -203,7 +203,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -316,7 +316,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: + def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index d225ecff6..54ac46d8d 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace(object): +class Namespace: def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 468d469a7..231052f73 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr(object): + class contextmgr: def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return "{0}/auth".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/auth" @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -42,7 +42,7 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: Creates a context manager that will sign out of the server upon exit. """ - url = "{0}/{1}".format(self.baseurl, "signin") + url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -63,7 +63,7 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. @@ -78,7 +78,7 @@ def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: @api(version="2.0") def sign_out(self) -> None: - url = "{0}/{1}".format(self.baseurl, "signout") + url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -88,7 +88,7 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -104,11 +104,11 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") + url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 57a5b0100..baed91149 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -2,7 +2,7 @@ import logging import os from pathlib import Path -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB from tableauserverclient.filesys_helpers import get_file_object_size @@ -33,11 +33,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super(CustomViews, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" @property def expurl(self) -> str: @@ -55,7 +55,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -68,8 +68,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying custom view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying custom view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,10 +83,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for custom view (ID: {view_item.id})") def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -105,10 +105,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = "{0}/{1}".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}" update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info("Updated custom view (ID: {0})".format(view_item.id)) + logger.info(f"Updated custom view (ID: {view_item.id})") return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -117,9 +117,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, view_id) + url = f"{self.baseurl}/{view_id}" self.delete_request(url) - logger.info("Deleted single custom view (ID: {0})".format(view_id)) + logger.info(f"Deleted single custom view (ID: {view_id})") @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e766..579001156 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super(DataAccelerationReport, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index fd02d2e4a..ba3ecd74f 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(DataAlerts, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") + url = f"{self.baseurl}/{dataAlert_id}" server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + url = f"{self.baseurl}/{dataAlert_id}" self.delete_request(url) - logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) + logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" self.delete_request(url) - logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) + logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}/users" update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) + logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}" update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) + logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2f8fece07..c0e106eb2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Union, Iterable, Set +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -15,7 +16,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super(Databases, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -23,7 +24,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") def get(self, req_options=None): @@ -40,8 +41,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info("Querying single database (ID: {0})".format(database_id)) - url = "{0}/{1}".format(self.baseurl, database_id) + logger.info(f"Querying single database (ID: {database_id})") + url = f"{self.baseurl}/{database_id}" server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +51,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, database_id) + url = f"{self.baseurl}/{database_id}" self.delete_request(url) - logger.info("Deleted single database (ID: {0})".format(database_id)) + logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") def update(self, database_item): @@ -60,10 +61,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}" update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info("Updated database item (ID: {0})".format(database_item.id)) + logger.info(f"Updated database item (ID: {database_item.id})") updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -78,10 +79,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info("Populated tables for database (ID: {0}".format(database_item.id)) + logger.info(f"Populated tables for database (ID: {database_item.id}") def _get_tables_for_database(self, database_item): - url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}/tables" server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -127,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f3a47075..38ef50751 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -57,7 +58,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super(Datasources, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -65,11 +66,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,8 +84,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info("Querying single datasource (ID: {0})".format(datasource_id)) - url = "{0}/{1}".format(self.baseurl, datasource_id) + logger.info(f"Querying single datasource (ID: {datasource_id})") + url = f"{self.baseurl}/{datasource_id}" server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,10 +100,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections(self, datasource_item, req_options=None): - url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -113,9 +114,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}" self.delete_request(url) - logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) + logger.info(f"Deleted single datasource (ID: {datasource_id})") # Download 1 datasource by id @api(version="2.0") @@ -152,11 +153,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = "{0}/{1}".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}" update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) + logger.info(f"Updated datasource item (ID: {datasource_item.id})") updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -165,7 +166,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -174,18 +175,16 @@ def update_connection( return None if len(connections) > 1: - logger.debug("Multiple connections returned ({0})".format(len(connections))) + logger.debug(f"Multiple connections returned ({len(connections)})") connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info( - "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) - ) + logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -194,7 +193,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -203,7 +202,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -223,12 +222,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) + logger.debug(f"Publishing file `{filename}`, size `{file_size}`") # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -247,10 +246,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = "Unsupported file type {}".format(file_type) + error = f"Unsupported file type {file_type}" raise ValueError(error) - filename = "{}.{}".format(datasource_item.name, file_extension) + filename = f"{datasource_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -261,12 +260,12 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?datasourceType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: @@ -276,12 +275,12 @@ def publish( ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -309,11 +308,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) + logger.info(f"Published {filename} (JOB_ID: {new_job.id}") return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) + logger.info(f"Published {filename} (ID: {new_datasource.id})") return new_datasource @api(version="3.13") @@ -327,23 +326,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = "{0}/{1}/data".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/data" elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" else: assert isinstance(datasource_or_connection_item, str) - url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + url = f"{self.baseurl}/{datasource_or_connection_item}/data" if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) - logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + logger.info(f"Uploading {payload} to server with chunking method for Update job") upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}?uploadSessionId={upload_session_id}" json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -356,7 +355,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -390,12 +389,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -413,9 +412,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -437,9 +436,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) - ) + logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path @api(version="2.3") @@ -449,19 +446,17 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info( - "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) - ) + logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 19112d713..343d8b097 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,8 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Union +from collections.abc import Sequence if TYPE_CHECKING: from ..server import Server @@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -33,18 +34,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl()) + return f"" __repr__ = __str__ def update_default_permissions( self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(f"Updated default {content_type} permissions for resource {resource.id}") logger.info(permissions) return permissions @@ -65,29 +66,27 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c ) ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> List[PermissionsRule]: + def permission_fetcher() -> list[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) + logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 5296523ee..90e31483b 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def update(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def clear(self, resource): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_data_quality_warnings(self, item, req_options=None): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index be0602df5..bef96fdee 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,12 +8,9 @@ from typing import ( Any, Callable, - Dict, Generic, - List, Optional, TYPE_CHECKING, - Tuple, TypeVar, Union, ) @@ -56,7 +53,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -82,7 +79,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -90,12 +87,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: response = method(url, **parameters) - logger.debug("[{}] Call finished".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: - logger.debug("Error making request to server: {}".format(e)) + logger.debug(f"Error making request to server: {e}") raise e return response @@ -111,13 +108,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[Dict[str, Any]] = None, + parameters: Optional[dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request method {}, url: {}".format(method.__name__, url)) + logger.debug(f"request method {method.__name__}, url: {url}") if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -129,14 +126,14 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug("[{}] Request failed".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Request failed") raise RuntimeError if isinstance(server_response, Exception): raise server_response @@ -154,9 +151,9 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug("Response status: {}".format(server_response)) + logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): - raise EnvironmentError("Response is not a http response?") + raise OSError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: @@ -183,9 +180,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type `{}`".format(content_type) + loggable_response = f"Content type `{content_type}`" if content_type == "application/octet-stream": - loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + loggable_response = f"A stream of type {content_type} [Truncated File Contents]" elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -313,7 +310,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) + error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" warnings.warn(error) return func(self, *args, **kwargs) @@ -353,5 +350,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9dfd38da6..17d789d01 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -12,10 +12,10 @@ def __init__(self, code, summary, detail, url=None): self.summary = summary self.detail = detail self.url = url - super(ServerResponseError, self).__init__(str(self)) + super().__init__(str(self)) def __str__(self): - return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) + return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod def from_response(cls, resp, ns, url=None): @@ -40,7 +40,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) + return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" class MissingRequiredFieldError(TableauError): diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37e..8330e6d2c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info("Querying all favorites for user {0}".format(user_item.name)) - url = "{0}/{1}".format(self.baseurl, user_item.id) + logger.info(f"Querying all favorites for user {user_item.name}") + url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) + logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) + logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) + logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) - logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" + logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" + logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" + logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" + logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" + logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" + logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) - logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" + logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 0d30797c1..1ae10e72d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Fileuploads, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self): - return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + logger.info(f"Initiated file upload session (ID: {upload_id})") return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = "{0}/{1}".format(self.baseurl, upload_id) + url = f"{self.baseurl}/{upload_id}" server_response = self.put_request(url, data, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) + logger.info(f"Uploading a chunk to session (ID: {upload_id})") return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,12 +52,10 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug("{} processing chunk...".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} processing chunk...") request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug("{} created chunk request".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info( - "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) - ) - logger.info("File upload finished (ID: {0})".format(upload_id)) + logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index c339a0645..3d09ad569 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException @@ -16,16 +16,16 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super(FlowRuns, self).__init__(parent_srv) + super().__init__(parent_srv) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -39,8 +39,8 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_run_id)) - url = "{0}/{1}".format(self.baseurl, flow_run_id) + logger.info(f"Querying single flow (ID: {flow_run_id})") + url = f"{self.baseurl}/{flow_run_id}" server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,9 +51,9 @@ def cancel(self, flow_run_id: str) -> None: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = "{0}/{1}".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}" self.put_request(url) - logger.info("Deleted single flow (ID: {0})".format(id_)) + logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -69,7 +69,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) + logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index eea3f9710..9e21661e6 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 53d072f50..7eb5dc3ba 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,8 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.helpers.headers import fix_filename @@ -53,18 +54,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super(Flows, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -78,8 +79,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_id)) - url = "{0}/{1}".format(self.baseurl, flow_id) + logger.info(f"Querying single flow (ID: {flow_id})") + url = f"{self.baseurl}/{flow_id}" server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -94,10 +95,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) + logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -108,9 +109,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}" self.delete_request(url) - logger.info("Deleted single flow (ID: {0})".format(flow_id)) + logger.info(f"Deleted single flow (ID: {flow_id})") # Download 1 flow by id @api(version="3.3") @@ -118,7 +119,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}/content" with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -137,7 +138,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path # Update flow @@ -150,28 +151,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = "{0}/{1}".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}" update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info("Updated flow item (ID: {0})".format(flow_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id})") updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -180,7 +181,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -189,7 +190,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -213,30 +214,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = "{}.{}".format(flow_item.name, file_extension) + filename = f"{flow_item.name}.{file_extension}" file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = "{0}?flowType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?flowType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) + logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -259,7 +260,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) + logger.info(f"Published {filename} (ID: {new_flow.id})") return new_flow @api(version="3.3") @@ -294,7 +295,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8acf31692..c512b011b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -19,10 +20,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -50,12 +51,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> Tuple[List[UserItem], PaginationItem]: - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + ) -> tuple[list[UserItem], PaginationItem]: + url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Populated users for group (ID: {0})".format(group_item.id)) + logger.info(f"Populated users for group (ID: {group_item.id})") return user_item, pagination_item @api(version="2.0") @@ -64,13 +65,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, group_id) + url = f"{self.baseurl}/{group_id}" self.delete_request(url) - logger.info("Deleted single group (ID: {0})".format(group_id)) + logger.info(f"Deleted single group (ID: {group_id})") @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = "{0}/{1}".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}" if not group_item.id: error = "Group item missing ID." @@ -83,7 +84,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info("Updated group item (ID: {0})".format(group_item.id)) + logger.info(f"Updated group item (ID: {group_item.id})") if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -118,9 +119,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) + url = f"{self.baseurl}/{group_item.id}/users/{user_id}" self.delete_request(url) - logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -132,7 +133,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info("Removed users to group (ID: {0})".format(group_item.id)) + logger.info(f"Removed users to group (ID: {group_item.id})") return None @api(version="2.0") @@ -144,15 +145,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}/users" add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -162,7 +163,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Added users to group (ID: {0})".format(group_item.id)) + logger.info(f"Added users to group (ID: {group_item.id})") return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 06e7cc627..c7f5ed0e5 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> Tuple[List[GroupSetItem], PaginationItem]: + ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index ae8cf2633..723d3dd38 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, Tuple, Union +from typing import Optional, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 374130509..ede4d38e3 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 38c3eebb6..e5dbcbcf8 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/graphql" @property def control_baseurl(self): - return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/v1/control" @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index ab1ec5852..3fea1f5b6 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super(Metrics, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info("Querying single metric (ID: {0})".format(metric_id)) - url = "{0}/{1}".format(self.baseurl, metric_id) + logger.info(f"Querying single metric (ID: {metric_id})") + url = f"{self.baseurl}/{metric_id}" server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, metric_id) + url = f"{self.baseurl}/{metric_id}" self.delete_request(url) - logger.info("Deleted single metric (ID: {0})".format(metric_id)) + logger.info(f"Deleted single metric (ID: {metric_id})") # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = "{0}/{1}".format(self.baseurl, metric_item.id) + url = f"{self.baseurl}/{metric_item.id}" update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + logger.info(f"Updated metric item (ID: {metric_item.id})") return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 4433625f2..10d420ff7 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, List, Optional, Union +from typing import Callable, TYPE_CHECKING, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_PermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl) + return f"" - def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: - url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) + def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/permissions" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) + logger.info(f"Updated permissions for resource {resource.id}: {permissions}") return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + url = "{}/{}/permissions/{}/{}/{}/{}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,13 +63,11 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate(self, item: TableauItem): if not item.id: @@ -80,12 +78,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) + logger.info(f"Permissions for resource {item.id}: {permissions}") return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 565817e37..4d139fe66 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -20,17 +20,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super(Projects, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -43,9 +43,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, project_id) + url = f"{self.baseurl}/{project_id}" self.delete_request(url) - logger.info("Deleted single project (ID: {0})".format(project_id)) + logger.info(f"Deleted single project (ID: {project_id})") @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -54,10 +54,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = "{0}/{1}".format(self.baseurl, project_item.id) + url = f"{self.baseurl}/{project_item.id}" update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info("Updated project item (ID: {0})".format(project_item.id)) + logger.info(f"Updated project item (ID: {project_item.id})") updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -66,11 +66,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) + url = f"{self.baseurl}?publishSamples={project_item._samples}" create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new project (ID: {0})".format(new_project.id)) + logger.info(f"Created new project (ID: {new_project.id})") return new_project @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8a..63c03b3e3 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,7 @@ import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from collections.abc import Iterable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -24,7 +25,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = "{0}/{1}/tags".format(baseurl, resource_id) + url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -39,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) + url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: self.delete_request(url) @@ -59,7 +60,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info("Updated tags to {0}".format(resource_item.tags)) + logger.info(f"Updated tags to {resource_item.tags}") class Response(Protocol): @@ -68,8 +69,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: Set[str] - _initial_tags: Set[str] + tags: set[str] + _initial_tags: set[str] @property def id(self) -> Optional[str]: @@ -95,14 +96,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -118,7 +119,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -158,9 +159,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -170,9 +171,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index cfaee3324..4ed243b25 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return "{0}/schedules".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/schedules" @property def siteurl(self) -> str: - return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info("Querying a single schedule by id ({})".format(schedule_id)) - url = "{0}/{1}".format(self.baseurl, schedule_id) + logger.info(f"Querying a single schedule by id ({schedule_id})") + url = f"{self.baseurl}/{schedule_id}" server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, schedule_id) + url = f"{self.baseurl}/{schedule_id}" self.delete_request(url) - logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + logger.info(f"Deleted single schedule (ID: {schedule_id})") @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, schedule_item.id) + url = f"{self.baseurl}/{schedule_item.id}" update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + logger.info(f"Updated schedule item (ID: {schedule_item.id})") updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + logger.info(f"Created new schedule (ID: {new_schedule.id})") return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> List[AddResponse]: + ) -> list[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: List[ - Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: list[ + tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -133,13 +133,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + url = f"{self.siteurl}/{schedule_id}/{type_}s" add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 26aaf2910..ab731c11b 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -21,11 +21,11 @@ def serverInfo(self): return self._info def __repr__(self): - return "".format(self.serverInfo) + return f"" @property def baseurl(self): - return "{0}/serverInfo".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") def get(self): diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index dfec49ae1..0f3d25908 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..request_options import RequestOptions @@ -17,11 +17,11 @@ class Sites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/sites" # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -40,8 +40,8 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info("Querying single site (ID: {0})".format(site_id)) - url = "{0}/{1}".format(self.baseurl, site_id) + logger.info(f"Querying single site (ID: {site_id})") + url = f"{self.baseurl}/{site_id}" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,8 +52,8 @@ def get_by_name(self, site_name: str) -> SiteItem: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info("Querying single site (Name: {0})".format(site_name)) - url = "{0}/{1}?key=name".format(self.baseurl, site_name) + logger.info(f"Querying single site (Name: {site_name})") + url = f"{self.baseurl}/{site_name}?key=name" print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -68,9 +68,9 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.info(f"Querying single site (Content URL: {content_url})") logger.debug("Querying other sites requires Server Admin permissions") - url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + url = f"{self.baseurl}/{content_url}?key=contentUrl" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -90,10 +90,10 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_item.id) + url = f"{self.baseurl}/{site_item.id}" update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info("Updated site item (ID: {0})".format(site_item.id)) + logger.info(f"Updated site item (ID: {site_item.id})") update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -103,13 +103,13 @@ def delete(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}" if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) + logger.info(f"Deleted single site (ID: {site_id}) and signed out") # Create new site @api(version="2.0") @@ -123,7 +123,7 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new site (ID: {0})".format(new_site.id)) + logger.info(f"Created new site (ID: {new_site.id})") return new_site @api(version="3.5") @@ -131,7 +131,7 @@ def encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/encrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -140,7 +140,7 @@ def decrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/decrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -149,7 +149,7 @@ def re_encrypt_extracts(self, site_id: str) -> None: if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/reencrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a9f2e7bf5..c9abc9b06 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info("Querying a single subscription by id ({})".format(subscription_id)) - url = "{}/{}".format(self.baseurl, subscription_id) + logger.info(f"Querying a single subscription by id ({subscription_id})") + url = f"{self.baseurl}/{subscription_id}" server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info("Creating a subscription ({})".format(subscription_item)) + logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, subscription_id) + url = f"{self.baseurl}/{subscription_id}" self.delete_request(url) - logger.info("Deleted subscription (ID: {0})".format(subscription_id)) + logger.info(f"Deleted subscription (ID: {subscription_id})") @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, subscription_item.id) + url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) + logger.info(f"Updated subscription item (ID: {subscription_item.id})") return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 36ef78c0a..120d3ba9c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Iterable, Set, Union +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -15,14 +16,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super(Tables, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") def get(self, req_options=None): @@ -39,8 +40,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info("Querying single table (ID: {0})".format(table_id)) - url = "{0}/{1}".format(self.baseurl, table_id) + logger.info(f"Querying single table (ID: {table_id})") + url = f"{self.baseurl}/{table_id}" server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -49,9 +50,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, table_id) + url = f"{self.baseurl}/{table_id}" self.delete_request(url) - logger.info("Deleted single table (ID: {0})".format(table_id)) + logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") def update(self, table_item): @@ -59,10 +60,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}" update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info("Updated table item (ID: {0})".format(table_item.id)) + logger.info(f"Updated table item (ID: {table_item.id})") updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -80,10 +81,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info("Populated columns for table (ID: {0}".format(table_item.id)) + logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table(self, table_item, req_options=None): - url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,12 +92,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) + logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") return column @api(version="3.5") @@ -128,7 +129,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a727a515f..eb82c43bc 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return "{}es".format(task_type) + return f"{task_type}es" else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> Tuple[List[TaskItem], PaginationItem]: + ) -> tuple[list[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) return server_response.content @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format( + url = "{}/{}/{}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index c4b6418b7..793638396 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Optional, Tuple +from typing import Optional from tableauserverclient.server.query import QuerySet @@ -16,11 +16,11 @@ class Users(QuerysetEndpoint[UserItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -39,8 +39,8 @@ def get_by_id(self, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info("Querying single user (ID: {0})".format(user_id)) - url = "{0}/{1}".format(self.baseurl, user_id) + logger.info(f"Querying single user (ID: {user_id})") + url = f"{self.baseurl}/{user_id}" server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() @@ -51,10 +51,10 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info("Updated user item (ID: {0})".format(user_item.id)) + logger.info(f"Updated user item (ID: {user_item.id})") updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -64,27 +64,27 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, user_id) + url = f"{self.baseurl}/{user_id}" if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info("Removed single user (ID: {0})".format(user_id)) + logger.info(f"Removed single user (ID: {user_id})") # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: url = self.baseurl - logger.info("Add user {}".format(user_item.name)) + logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added new user (ID: {0})".format(new_user.id)) + logger.info(f"Added new user (ID: {new_user.id})") return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: List[UserItem]): + def add_all(self, users: list[UserItem]): created = [] failed = [] for user in users: @@ -98,7 +98,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -133,10 +133,10 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[WorkbookItem], PaginationItem]: - url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) + ) -> tuple[list[WorkbookItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/workbooks" server_response = self.get_request(url, req_options) - logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item @@ -161,10 +161,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[GroupItem], PaginationItem]: - url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + ) -> tuple[list[GroupItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) - logger.info("Populated groups for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated groups for user (ID: {user_item.id})") group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f2ccf658e..3709fc41d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,7 +11,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Iterator if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -25,22 +26,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super(Views, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" @property def baseurl(self) -> str: - return "{0}/views".format(self.siteurl) + return f"{self.siteurl}/views" @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> Tuple[List[ViewItem], PaginationItem]: + ) -> tuple[list[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -55,8 +56,8 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying single view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying single view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -72,10 +73,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated preview image for view (ID: {view_item.id})") def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) + url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" server_response = self.get_request(url) image = server_response.content return image @@ -90,10 +91,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for view (ID: {view_item.id})") def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -108,10 +109,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated pdf for view (ID: {view_item.id})") def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -126,10 +127,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info("Populated csv for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated csv for view (ID: {view_item.id})") def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/data".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{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) @@ -144,10 +145,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated excel for view (ID: {view_item.id})") def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -176,7 +177,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index f71db00cc..944b72502 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,8 @@ from functools import partial import json from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -28,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +45,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[ConnectionItem], PaginationItem]: + ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,7 +84,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[RevisionItem], PaginationItem]: + ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -159,7 +160,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> Set[str]: + ) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 597f9c425..06643f99d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(Webhooks, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: 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) + logger.info(f"Querying single webhook (ID: {webhook_id})") + url = f"{self.baseurl}/{webhook_id}" server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}" self.delete_request(url) - logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) + logger.info(f"Deleted single webhook (ID: {webhook_id})") @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: 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)) + logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}/test".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}/test" testOutcome = self.get_request(url) - logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) + logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index da6eda3de..5e4442b60 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -25,15 +25,11 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, - List, Optional, - Sequence, - Set, - Tuple, TYPE_CHECKING, Union, ) +from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -61,18 +57,18 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super(Workbooks, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -86,15 +82,15 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info("Querying single workbook (ID: {0})".format(workbook_id)) - url = "{0}/{1}".format(self.baseurl, workbook_id) + logger.info(f"Querying single workbook (ID: {workbook_id})") + url = f"{self.baseurl}/{workbook_id}" server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -107,10 +103,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -121,7 +117,7 @@ def create_extract( @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -133,9 +129,9 @@ def delete(self, workbook_id: str) -> None: if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}" self.delete_request(url) - logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) + logger.info(f"Deleted single workbook (ID: {workbook_id})") # Update workbook @api(version="2.0") @@ -152,27 +148,25 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = "{0}/{1}".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}" if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) + logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) + url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info( - "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) - ) + logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection # Download workbook contents with option of passing in filepath @@ -199,14 +193,14 @@ def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> No error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> List[ViewItem]: + def view_fetcher() -> list[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated views for workbook (ID: {workbook_item.id})") - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: - url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: + url = f"{self.baseurl}/{workbook_item.id}/views" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -228,12 +222,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) + ) -> list[ConnectionItem]: + url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -249,10 +243,10 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -267,10 +261,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -286,10 +280,10 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/previewImage" server_response = self.get_request(url) preview_image = server_response.content return preview_image @@ -322,7 +316,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -346,12 +340,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = "{}.{}".format(workbook_item.name, file_extension) + filename = f"{workbook_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -362,30 +356,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?workbookType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") if skip_connection_check: - url += "&{0}=true".format("skipConnectionCheck") + url += "&{}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) + logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -403,7 +397,7 @@ def publish( file_contents, connections=connections, ) - logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) + logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") # Send the publishing request to server try: @@ -415,11 +409,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) + logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) + logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") return new_workbook # Populate workbook item's revisions @@ -433,12 +427,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -456,9 +450,9 @@ def download_revision( error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -480,9 +474,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) - ) + logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path @api(version="2.3") @@ -492,17 +484,17 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index b936ceb92..fd90e281f 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter(object): +class Filter: def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return "{0}:{1}:{2}".format(self.field, self.operator, value_string) + return f"{self.field}:{self.operator}:{value_string}" @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ca9d83872..e6d261b61 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,7 @@ import copy from functools import partial -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -11,14 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: - ... + def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: - ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -27,7 +26,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (List[ModelItem], PaginationItem). + Will loop over anything that returns (list[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index bbca612e9..e72b29ab2 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import Sized from itertools import count -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +from collections.abc import Iterable, Iterator from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter @@ -37,7 +38,7 @@ class QuerySet(Iterable[T], Sized): def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: List[T] = [] + self._result_cache: list[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -56,12 +57,10 @@ def __iter__(self: Self) -> Iterator[T]: return @overload - def __getitem__(self: Self, k: Slice) -> List[T]: - ... + def __getitem__(self: Self, k: Slice) -> list[T]: ... @overload - def __getitem__(self: Self, k: int) -> T: - ... + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): page = self.page_number @@ -160,22 +159,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError("Operator `{}` is not valid.".format(operator)) + raise ValueError(f"Operator `{operator}` is not valid.") field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError("Field name `{}` is not valid.".format(field)) + raise ValueError(f"Field name `{field}` is not valid.") return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(key: str) -> tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 96fa14680..f7bd139d7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from collections.abc import Iterable from typing_extensions import ParamSpec @@ -15,7 +16,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: Dict) -> Tuple[Any, str]: +def _add_multipart(parts: dict) -> tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -80,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest(object): +class AuthRequest: def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -104,7 +105,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest(object): +class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -115,7 +116,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest(object): +class DataAlertRequest: def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -140,7 +141,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest(object): +class DatabaseRequest: def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -159,7 +160,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest(object): +class DatasourceRequest: def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -244,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest(object): +class DQWRequest: def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -274,7 +275,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest(object): +class FavoriteRequest: def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -329,7 +330,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest(object): +class FileuploadRequest: def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -338,8 +339,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest(object): - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: +class FlowRequest: + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -370,8 +371,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[List["ConnectionItem"]] = None, - ) -> Tuple[Any, str]: + connections: Optional[list["ConnectionItem"]] = None, + ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -380,14 +381,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest(object): +class GroupRequest: def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -477,7 +478,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest(object): +class PermissionRequest: def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -499,7 +500,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest(object): +class ProjectRequest: def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -530,7 +531,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest(object): +class ScheduleRequest: def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -609,7 +610,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest(object): +class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -848,7 +849,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest(object): +class TableRequest: def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -871,7 +872,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest(object): +class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -881,7 +882,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -897,7 +898,7 @@ def batch_create(self, element: ET.Element, tags: Set[str], content: content_typ return ET.tostring(element) -class UserRequest(object): +class UserRequest: def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -931,7 +932,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest(object): +class WorkbookRequest: def _generate_xml( self, workbook_item, @@ -995,9 +996,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib[ - "frequency" - ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["frequency"] = ( + data_freshness_policy_config.fresh_every_schedule.frequency + ) fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1075,7 +1076,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection(object): +class Connection: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1098,7 +1099,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest(object): +class TaskRequest: @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1137,7 +1138,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest(object): +class FlowTaskRequest: @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1171,7 +1172,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest(object): +class SubscriptionRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1235,13 +1236,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest(object): +class EmptyRequest: @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest(object): +class WebhookRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1287,7 +1288,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest(object): +class CustomViewRequest: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1415,7 +1416,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory(object): +class RequestFactory: Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index ddb45834d..fedf3ab45 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -9,12 +9,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase(object): +class RequestOptionsBase: # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + params_list = [f"{k}={v}" for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -22,7 +22,7 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, "&".join(params_list)) + return "{}?{}".format(url, "&".join(params_list)) except NotImplementedError: raise @@ -183,7 +183,7 @@ def _append_view_filters(self, params) -> None: class CSVRequestOptions(_FilterOptionsBase): def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__init__() + super().__init__() self.max_age = maxage @property @@ -233,7 +233,7 @@ class Resolution: High = "high" def __init__(self, imageresolution=None, maxage=-1): - super(ImageRequestOptions, self).__init__() + super().__init__() self.image_resolution = imageresolution self.max_age = maxage @@ -278,7 +278,7 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super(PDFRequestOptions, self).__init__() + super().__init__() self.page_type = page_type self.orientation = orientation self.max_age = maxage diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e563a7138..dab5911db 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,7 +58,7 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +class Server: class PublishMode: Append = "Append" Overwrite = "Overwrite" @@ -130,7 +130,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return "".format(self.baseurl, self.server_info.serverInfo) + return f"" def add_http_options(self, options_dict: dict): try: @@ -142,7 +142,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError("Invalid http options given: {}".format(options_dict)) + raise ValueError(f"Invalid http options given: {options_dict}") def clear_http_options(self): self._http_options = dict() @@ -176,15 +176,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except Exception as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = None - logger.info("versions: {}, {}".format(version, old_version)) + logger.info(f"versions: {version}, {old_version}") return version or old_version def use_server_version(self): @@ -201,12 +201,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) + error = f"{reason} is not available in API version {self.version}. Requires {comparison}" raise EndpointUnavailableError(error) @property def baseurl(self): - return "{0}/api/{1}".format(self._server_address, str(self.version)) + return f"{self._server_address}/api/{str(self.version)}" @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 2d6bc030a..839a8c8db 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort(object): +class Sort: def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return "{0}:{1}".format(self.field, self.direction) + return f"{self.field}:{self.direction}" diff --git a/test/test_dataalert.py b/test/test_dataalert.py index d9e00a9db..6f6f1683c 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 624eb93e1..45d9ba9c9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) + self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 8635af978..ff1ef0f72 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse(object): + class FakeResponse: headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 6f0be3b3c..87332d70f 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) + m.put(f"{baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) + m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) + m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) + m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) + m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 4c8fb0f9f..0f3234d5d 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write("This is a zip file".encode()) + stream.write(b"This is a zip file") zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 50a5ef48b..9567bc3ad 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 864c0d3cd..e1ddd5541 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,6 +95,6 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e64..2d9f7c7bd 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index fc9c75a6d..41b5992be 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,4 +1,3 @@ -# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index d86397086..20b238764 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_project.py b/test/test_project.py index e05785f86..430db84b2 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 772704f69..62e301591 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,9 +1,5 @@ import unittest - -try: - from unittest import mock -except ImportError: - import mock # type: ignore[no-redef] +from unittest import mock import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index e48f8510a..9ca9779ad 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): diff --git a/test/test_schedule.py b/test/test_schedule.py index 0377295d7..1d329f86e 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_site_model.py b/test/test_site_model.py index f62eb66f0..60ad9c5e5 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,5 +1,3 @@ -# coding=utf-8 - import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 0184af415..23dffebfb 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from typing import Iterable +from collections.abc import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = set(["x", "y", "z"]) + initial_tags = {"x", "y", "z"} item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 53da7c160..2d724b879 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) + m.get(f"{self.baseurl}/{task_id}", text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) + m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index 1f5eba57f..a46624845 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,5 @@ -import io import os import unittest -from typing import List -from unittest.mock import MagicMock import requests_mock @@ -163,7 +160,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -176,7 +173,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) + m.get(f"{baseurl}/{single_user.id}", text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index d0997b9ff..a8a2c51cb 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,7 +1,6 @@ import logging import unittest from unittest.mock import * -from typing import List import io import pytest @@ -107,7 +106,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -119,10 +118,10 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == [], "Expected no failures, got {}".format(invalid) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" diff --git a/test/test_view.py b/test/test_view.py index 1c667a4c3..a89a6d235 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) + self.assertEqual({"tag1", "tag2"}, all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 6f94f0c10..766831b0a 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..1a6b3192f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index 86c240e13..cce899f58 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,7 +276,6 @@ """ -from __future__ import print_function try: import configparser @@ -328,7 +327,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) + print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") except NameError: pass return root @@ -342,7 +341,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: + with open(setup_cfg) as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -398,7 +397,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -408,7 +407,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}" return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -423,7 +422,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -955,7 +954,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -970,7 +969,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -994,11 +993,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1007,7 +1006,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1100,7 +1099,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1145,13 +1144,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") + f = open(".gitattributes") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except EnvironmentError: + except OSError: pass if not present: f = open(".gitattributes", "a+") @@ -1185,7 +1184,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1212,7 +1211,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1229,7 +1228,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print("set %s to '%s'" % (filename, versions["version"])) + print(f"set {filename} to '{versions['version']}'") def plus_or_dot(pieces): @@ -1452,7 +1451,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) + print(f"got version from file {versionfile_abs} {ver}") return ver except NotThisMethod: pass @@ -1723,7 +1722,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1748,9 +1747,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy, "r") as f: + with open(ipy) as f: old = f.read() - except EnvironmentError: + except OSError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1769,12 +1768,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in, "r") as f: + with open(manifest_in) as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except EnvironmentError: + except OSError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1805,7 +1804,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py", "r") as f: + with open("setup.py") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 2a7fb2bf5e63411e1aac1b4cea0a93c6171740eb Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 01:36:53 -0500 Subject: [PATCH 504/567] chore(typing): include samples in type checks (#1455) * chore(typing): include samples in type checks Including the sample scripts in type checking will allow more thorough testing to validate the samples work as expected, as well as more testing around how a library consumer may use the library. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac --- pyproject.toml | 2 +- samples/explore_favorites.py | 6 +++--- samples/list.py | 3 +++ tableauserverclient/models/favorites_item.py | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc3bf8fab..c3cb67eda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ disable_error_code = [ # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] -files = ["tableauserverclient", "test"] +files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 364e078cc..f199522ed 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient import Resource +from tableauserverclient.models import Resource def main(): @@ -46,8 +46,8 @@ def main(): # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook: TSC.WorkbookItem = all_workbook_items[0] - server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + my_workbook = all_workbook_items[0] + server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id diff --git a/samples/list.py b/samples/list.py index 11e664695..2675a2954 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,6 +48,9 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) + if endpoint is None: + print("Resource type not found.") + sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index f157283cb..4fea280f7 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,8 +1,9 @@ import logging +from typing import Union from defusedxml.ElementTree import fromstring -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem @@ -20,7 +21,7 @@ class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: dict) -> FavoriteType: + def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], From 6ec632e328a744be4be733b1a0c697f74bf3a3c1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 20 Sep 2024 15:03:13 -0500 Subject: [PATCH 505/567] fix: queryset support for flowruns (#1460) * fix: queryset support for flowruns FlowRun's get endpoint does not return a PaginationItem. This provides a tweak to QuerySet to provide a workaround so all items matching whatever filters are supplied. It also corrects the return types of flowruns.get and fixes the XML test asset to reflect what is really returned by the server. * fix: set unknown size to sys.maxsize Users may length check a QuerySet as part of a normal workflow. A len of 0 would be misleading, indicating to the user that there are no matches for the endpoint and/or filters they supplied. __len__ must return a non-negative int. Sentinel values such as -1 or None do not work. This only leaves maxsize as the possible flag. * fix: docstring on QuerySet * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/flow_runs_endpoint.py | 14 ++-- tableauserverclient/server/query.py | 64 ++++++++++++++++--- test/_utils.py | 14 ++++ test/assets/flow_runs_get.xml | 3 +- test/test_flowruns.py | 17 ++++- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 3d09ad569..2c3bb84bc 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.models import FlowRunItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -25,13 +25,15 @@ def baseurl(self) -> str: # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowRunItem], PaginationItem]: + # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns + # does not return a PaginationItem. Suppressing the mypy error because the + # changes to the QuerySet class should permit this to function regardless. + def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items, pagination_item + return all_flow_run_items # Get 1 flow by id @api(version="3.10") @@ -46,7 +48,7 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: str) -> None: + def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index e72b29ab2..feebc1a7e 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -from collections.abc import Iterable, Iterator +import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -35,6 +36,32 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): + """ + QuerySet is a class that allows easy filtering, sorting, and iterating over + many endpoints in TableauServerClient. It is designed to be used in a similar + way to Django QuerySets, but with a more limited feature set. + + QuerySet is an iterable, and can be used in for loops, list comprehensions, + and other places where iterables are expected. + + QuerySet is also Sized, and can be used in places where the length of the + QuerySet is needed. The length of the QuerySet is the total number of items + available in the QuerySet, not just the number of items that have been + fetched. If the endpoint does not return a total count of items, the length + of the QuerySet will be sys.maxsize. If there is no total count, the + QuerySet will continue to fetch items until there are no more items to + fetch. + + QuerySet is not re-entrant. It is not designed to be used in multiple places + at the same time. If you need to use a QuerySet in multiple places, you + should create a new QuerySet for each place you need to use it, convert it + to a list, or create a deep copy of the QuerySet. + + QuerySets are also indexable, and can be sliced. If you try to access an + index that has not been fetched, the QuerySet will fetch the page that + contains the item you are looking for. + """ + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) @@ -50,10 +77,20 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + try: + self._fetch_all() + except ServerResponseError as e: + if e.code == "400006": + # If the endpoint does not support pagination, it will end + # up overrunning the total number of pages. Catch the + # error and break out of the loop. + raise StopIteration yield from self._result_cache - # Set result_cache to empty so the fetch will populate - if (page * self.page_size) >= len(self): + # If the length of the QuerySet is unknown, continue fetching until + # the result cache is empty. + if (size := len(self)) == 0: + continue + if (page * self.page_size) >= size: return @overload @@ -114,10 +151,15 @@ def _fetch_all(self: Self) -> None: Retrieve the data and store result and pagination item in cache """ if not self._result_cache: - self._result_cache, self._pagination_item = self.model.get(self.request_options) + response = self.model.get(self.request_options) + if isinstance(response, tuple): + self._result_cache, self._pagination_item = response + else: + self._result_cache = response + self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available + return self.total_available or sys.maxsize @property def total_available(self: Self) -> int: @@ -127,12 +169,16 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_number + # If the PaginationItem is not returned from the endpoint, use the + # pagenumber from the RequestOptions. + return self._pagination_item.page_number or self.request_options.pagenumber @property def page_size(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_size + # If the PaginationItem is not returned from the endpoint, use the + # pagesize from the RequestOptions. + return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8c..b4ee93bc3 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -18,6 +19,19 @@ def read_xml_assets(*args): return map(read_xml_asset, args) +def server_response_error_factory(code: str, summary: str, detail: str) -> str: + root = ET.Element("tsResponse") + error = ET.SubElement(root, "error") + error.attrib["code"] = code + + summary_element = ET.SubElement(error, "summary") + summary_element.text = summary + + detail_element = ET.SubElement(error, "detail") + detail_element.text = detail + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index bdce4cdfb..489e8ac63 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/test/test_flowruns.py b/test/test_flowruns.py index e1ddd5541..8af2540dc 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,3 +1,4 @@ +import sys import unittest import requests_mock @@ -5,7 +6,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time +from ._utils import read_xml_asset, mocked_time, server_response_error_factory GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -28,9 +29,8 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs, pagination_item = self.server.flow_runs.get() + all_flow_runs = self.server.flow_runs.get() - self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -98,3 +98,14 @@ def test_wait_for_job_timeout(self) -> None: m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + def test_queryset(self) -> None: + response_xml = read_xml_asset(GET_XML) + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{self.baseurl}?pageNumber=2", text=error_response) + queryset = self.server.flow_runs.all() + assert len(queryset) == sys.maxsize From 9a310040c8d9da5f762cbe0bf62653619fff521b Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 28 Sep 2024 11:32:20 -0700 Subject: [PATCH 506/567] #1464 - docs update for filtering on boolean values (#1471) Add docs mention of boolean values for filtering --- tableauserverclient/server/request_options.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index fedf3ab45..a3ad0c498 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -164,13 +164,14 @@ def get_query_params(self): raise NotImplementedError() def vf(self, name: str, value: str) -> Self: - """Apply a filter to the view for a filter that is a normal column - within the view.""" + """Apply a filter based on a column within the view. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook.""" + """Apply a filter based on a parameter within the workbook. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_parameters.append((name, value)) return self From d480b7570c6d0db06ef7ac4cc8ab352c7c448807 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 30 Sep 2024 00:27:59 -0500 Subject: [PATCH 507/567] chore(versions): update remaining f-strings (#1477) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/_version.py | 4 ++-- tableauserverclient/server/endpoint/endpoint.py | 2 +- test/test_schedule.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 5d1dca9df..79dbed1d8 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried {}".format(commands)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories {} but none started with prefix {}".format(str(rootdirs), parentdir_prefix)) + print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index bef96fdee..29912de63 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -140,7 +140,7 @@ def _make_request( self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}".format(url)) + logger.debug(f"Server response from {url}") # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) diff --git a/test/test_schedule.py b/test/test_schedule.py index 1d329f86e..b072522a4 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) From e1b828120bd7164b3f20be43123335a977293784 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 30 Sep 2024 14:46:00 -0700 Subject: [PATCH 508/567] #1475 Add 'description' to datasource sample code (#1475) Update explore_datasource.py --- samples/explore_datasource.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index 877c5f08d..c9f35d5be 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,6 +51,7 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) + new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) @@ -72,6 +73,10 @@ def main(): print(f"\nConnections for {sample_datasource.name}: ") print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) + # Demonstrate that description is editable + sample_datasource.description = "Description updated by TSC" + server.datasources.update(sample_datasource) + # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") From b49eac5766e61260c6f8ba56d2671b8f44f5b1b1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:09:07 -0500 Subject: [PATCH 509/567] feat(exceptions): separate failed signin error (#1478) * feat(exceptions): separate failed signin error Closes #1472 This makes sign in failures their own class of exceptions, while still inheriting from NotSignedInException to not break backwards compatability for any existing client code. This should allow users to get out more specific exceptions more easily on what failed with their authentication request. * fix(error): raise exception when ServerInfo.get fails If ServerInfoItem.from_response gets invalid XML, raise the error immediately instead of suppressing the error and setting an invalid version number * fix(test): add missing test asset --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + .../models/server_info_item.py | 8 +-- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/endpoint.py | 3 +- .../server/endpoint/exceptions.py | 24 ++++++-- test/assets/server_info_wrong_site.html | 56 +++++++++++++++++++ test/test_auth.py | 6 +- test/test_server_info.py | 10 ++++ 8 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 test/assets/server_info_wrong_site.html diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05f..1299c33bc 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -79,6 +80,7 @@ "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "FailedSignInError", "FavoriteItem", "FlowItem", "FlowRunItem", diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 5c3f6acc7..4b299b29d 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -40,13 +40,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info(f"Unexpected response for ServerInfo: {resp}") - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d236..87cc9460b 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 29912de63..9e1160705 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -19,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -160,7 +161,7 @@ def _check_status(self, server_response: "Response", url: Optional[str] = None): try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 17d789d01..77332da3e 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,13 +1,20 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail @@ -18,7 +25,7 @@ def __str__(self): return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 000000000..e92daeb2d --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481e..48100ad88 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ecd..fa1472c9a 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get() From 9495fe8109aa30bab27dc262a10666ce8f55eb5c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 10 Oct 2024 14:36:33 -0500 Subject: [PATCH 510/567] docs: add docstrings to auth objects and endpoints (#1484) * docs: add docstrings to auth objects and endpoints * docs: add parameters and examples to methods --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_auth.py | 110 ++++++++++++++++++ .../server/endpoint/auth_endpoint.py | 57 +++++++++ 2 files changed, 167 insertions(+) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index c1e9d62bf..7d7981433 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 231052f73..4211bb7ea 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -41,6 +41,30 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) @@ -70,14 +94,17 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: + """Sign out of current session.""" url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -88,6 +115,33 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -109,6 +163,9 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: + """ + Revokes all personal access tokens for all server admins on the server. + """ url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") From 0af55124903e3902e37e5cb8126cdcf5a39f3aa2 Mon Sep 17 00:00:00 2001 From: Henning Merklinger Date: Thu, 10 Oct 2024 22:23:05 +0200 Subject: [PATCH 511/567] Set FILESIZE_LIMIT_MB via environment variables (#1466) * add TSC_FILESIZE_LIMIT_MB environment variable * add hard limit for filesize limit at 64MB * fix formatting --------- Co-authored-by: Jac --- tableauserverclient/config.py | 8 +++++--- .../server/endpoint/custom_views_endpoint.py | 4 ++-- .../server/endpoint/datasources_endpoint.py | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398f..a75112754 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index baed91149..63899ba0c 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional, Union -from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +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 @@ -144,7 +144,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 38ef50751..6bd809c28 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -23,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -268,10 +268,10 @@ def publish( url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) From c6dabddd993339b8a0d5edbc820234636f62914b Mon Sep 17 00:00:00 2001 From: AlbertWangXu Date: Thu, 10 Oct 2024 18:12:55 -0400 Subject: [PATCH 512/567] added PulseMetricDefine cap (#1490) Update permissions_item.py added PulseMetricDefine cap Co-authored-by: Jac --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 3e4fec22a..186cebedd 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -41,6 +41,7 @@ class Capability: RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" From 0efd7357d494141c17dda508252e884b8f772f5f Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Fri, 11 Oct 2024 00:14:17 +0200 Subject: [PATCH 513/567] Adding project permissions handling for databases, tables and virtual connections (#1482) --- tableauserverclient/models/project_item.py | 44 +++++++++++++------ .../server/endpoint/projects_endpoint.py | 36 +++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index d875abbdf..48f27c60c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,8 @@ class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 4d139fe66..773b942de 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -109,6 +109,18 @@ def populate_flow_default_permissions(self, item): def populate_lens_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Lens) + @api(version="3.23") + def populate_virtualconnection_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item): + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @@ -133,6 +145,18 @@ def update_flow_default_permissions(self, item, rules): def update_lens_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) + @api(version="3.23") + def update_virtualconnection_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions(self, item, rules): + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @@ -157,6 +181,18 @@ def delete_flow_default_permissions(self, item, rule): def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + @api(version="3.23") + def delete_virtualconnection_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item, rule): + self._default_permissions.delete_default_permission(item, rule, Resource.Table) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page From f8728b211d8e8eb4507f6e907e482da5a60d3577 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 15:58:32 -0500 Subject: [PATCH 514/567] docs: docstrings for Server and ServerInfo (#1494) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/server_info_item.py | 22 ++++++++ .../server/endpoint/server_info_endpoint.py | 41 +++++++++++++- tableauserverclient/server/server.py | 56 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 4b299b29d..b13f26740 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -7,6 +7,28 @@ class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index ab731c11b..dc934496a 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -24,12 +25,46 @@ def __repr__(self): return f"" @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index dab5911db..4eeefcaf9 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -59,7 +59,63 @@ class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" From 89e1ddf9aa3c509426acd7d247fcd1842ace996c Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 11 Oct 2024 14:00:13 -0700 Subject: [PATCH 515/567] refactor request_options, add language param (#1481) * refactor request_options, add language param I have refactored the classes to separate options that can be used in querying content, and options that can be used for exporting data. "language" is only available for data exporting. --- pyproject.toml | 2 +- samples/export.py | 14 +- tableauserverclient/__init__.py | 48 +++-- tableauserverclient/server/request_options.py | 199 ++++++++---------- test/test_request_option.py | 10 + 5 files changed, 129 insertions(+), 144 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3cb67eda..67faefbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,13 @@ target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] check_untyped_defs = false disable_error_code = [ 'misc', - # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/export.py b/samples/export.py index 815ec8b51..e33710468 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,7 +37,9 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--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("--file", "-f", help="filename to store the exported data") @@ -74,16 +76,18 @@ def main(): populate = getattr(server.workbooks, populate_func_name) option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = f"out.{extension}" + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 1299c33bc..e0a7abb64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -66,66 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index a3ad0c498..0d47abfcc 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -26,11 +27,48 @@ def apply_query_params(self, url): except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,51 +156,43 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def page_size(self, page_size): - self.pagesize = page_size - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def page_number(self, page_number): - self.pagenumber = page_number - return self - - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. @@ -182,82 +213,33 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super().__init__() - self.max_age = maxage - - @property - def max_age(self): - return self._max_age +class CSVRequestOptions(_DataExportOptions): + extension = "csv" - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - - self._append_view_filters(params) - return params - - -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage - - @property - def max_age(self) -> int: - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" - self._append_view_filters(params) - return params +class ImageRequestOptions(_DataExportOptions): + extension = "png" -class ImageRequestOptions(_FilterOptionsBase): # 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__() + super().__init__(maxage=maxage) self.image_resolution = imageresolution - self.max_age = maxage - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value def get_query_params(self): - params = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_DataExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -279,22 +261,12 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__() + super().__init__(maxage=maxage) self.page_type = page_type self.orientation = orientation - self.max_age = maxage self.viz_height = viz_height self.viz_width = viz_width - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - @property def viz_height(self): return self._viz_height @@ -313,17 +285,14 @@ def viz_width(self): def viz_width(self, value): self._viz_width = value - def get_query_params(self): - params = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - if self.max_age != -1: - params["maxAge"] = self.max_age - # 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") @@ -334,6 +303,4 @@ def get_query_params(self): if self.viz_width is not None: params["vizWidth"] = self.viz_width - self._append_view_filters(params) - return params diff --git a/test/test_request_option.py b/test/test_request_option.py index 9ca9779ad..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) From 1b64987b4eb63f5adee21c5c4eb0038d6045e15f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 11 Oct 2024 16:02:32 -0500 Subject: [PATCH 516/567] docs: docstrings for user item and endpoint (#1485) * docs: docstrings for user item and endpoint * docs: add serverresponseerror details --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/user_item.py | 30 ++ .../server/endpoint/users_endpoint.py | 347 ++++++++++++++++++ 2 files changed, 377 insertions(+) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fb29492e4..365e44c1d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -19,9 +19,34 @@ class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 793638396..d81907ae9 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -14,6 +14,14 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" @@ -21,6 +29,60 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,6 +98,49 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -47,6 +152,47 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -61,6 +207,31 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) @@ -73,6 +244,95 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) @@ -122,6 +382,42 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -142,11 +438,62 @@ def _get_wbs_for_user( return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) From 9b1b9406df55e1eee0baba7b52d5586a3854b83a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 14 Oct 2024 11:53:12 -0500 Subject: [PATCH 517/567] ci: build on python 3.13 (#1492) Now that python 3.13 has released, test builds on actual 3.13 instead of the 3.13-dev build Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7e1533eef..ac622795a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13-dev'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} From d880d520ee1b8d5d94e3d5a9caa896286fe2a9dd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:04:22 -0500 Subject: [PATCH 518/567] docs: workbook docstrings (#1488) Add detailed docstrings to workbook item and endpoint Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/workbook_item.py | 78 +++ .../server/endpoint/workbooks_endpoint.py | 598 +++++++++++++++++- 2 files changed, 674 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index ab5ff4157..776d041e3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -21,6 +21,84 @@ class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + id : Optional[str] + The identifier for the workbook. You need this value to query a specific + workbook or to delete a workbook with the get_by_id and delete methods. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5e4442b60..460017d1a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -69,6 +70,22 @@ def baseurl(self) -> str: # Get all workbooks on site @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,6 +96,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -89,6 +119,19 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() @@ -105,6 +148,33 @@ def create_extract( includeAll: bool = True, datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" @@ -116,6 +186,29 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) @@ -126,6 +219,18 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -141,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +289,28 @@ def update( # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -179,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -189,6 +367,36 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -214,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -235,6 +473,34 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the page type + and orientation of the PDF content, as well as the maximum age of + the PDF rendered on the server. See PDFRequestOptions class for more + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -253,6 +519,36 @@ def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reques @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -272,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -290,14 +606,65 @@ def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -313,6 +680,83 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -419,6 +863,28 @@ def publish( # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -446,6 +912,40 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) @@ -479,6 +979,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) @@ -491,18 +1013,90 @@ def delete_revision(self, workbook_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: From 9f59af159436a8f6b0da2f4155d8a9f4d37d2766 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 15 Oct 2024 02:05:50 -0500 Subject: [PATCH 519/567] chore: type hint default permissions endpoints (#1493) Resource is not currently an actual type, but an enum-like holder for literal values. Added a Union for str types to make mypy happy. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/tableau_types.py | 2 +- .../endpoint/default_permissions_endpoint.py | 10 ++- .../server/endpoint/projects_endpoint.py | 73 +++++++++++-------- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index ea2a5e4f8..01ee3d3a9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,7 +28,7 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 343d8b097..499324e8e 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -50,7 +50,9 @@ def update_default_permissions( return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -72,7 +74,7 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -84,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 773b942de..74bb865c7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import Optional, TYPE_CHECKING @@ -78,119 +79,133 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item, rules): + def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item, rules): + def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item): + def populate_workbook_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item): + def populate_datasource_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item): + def populate_metric_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item): + def populate_datarole_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item): + def populate_flow_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item): + def populate_lens_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="3.23") - def populate_virtualconnection_default_permissions(self, item): + def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) @api(version="3.23") - def populate_database_default_permissions(self, item): + def populate_database_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Database) @api(version="3.23") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="2.1") - def update_workbook_default_permissions(self, item, rules): + def update_workbook_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions(self, item, rules): + def update_datasource_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions(self, item, rules): + def update_metric_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions(self, item, rules): + def update_datarole_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item, rules): + def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item, rules): + def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="3.23") - def update_virtualconnection_default_permissions(self, item, rules): + def update_virtualconnection_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) @api(version="3.23") - def update_database_default_permissions(self, item, rules): + def update_database_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Database) @api(version="3.23") - def update_table_default_permissions(self, item, rules): + def update_table_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="2.1") - def delete_workbook_default_permissions(self, item, rule): + def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item, rule): + def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item, rule): + def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item, rule): + def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item, rule): + def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): + def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Lens) @api(version="3.23") - def delete_virtualconnection_default_permissions(self, item, rule): + def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) @api(version="3.23") - def delete_database_default_permissions(self, item, rule): + def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Database) @api(version="3.23") - def delete_table_default_permissions(self, item, rule): + def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Table) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: From 2ff96971ef7be58850121cca398111cc7810cec5 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:00:58 -0500 Subject: [PATCH 520/567] fix: handle 0 item response in querysets (#1501) * fix: handle 0 item response in querysets A flaw in the __iter__ logic introduced to handle scenarios where a pagination element is not included in the response xml resulted in an infinite loop. This PR introduces a few changes to protect against this: 1. After running QuerySet._fetch_all(), if the result_cache is empty, return instead of performing other comparisons. 2. Ensure that any non-None total_available is returned from the PaginationItem's object. 3. In _fetch_all, check if there is a PaginationItem that has been populated so as to not call the server side endpoint muliple times before returning. * fix: null out PaginationItem._page_number Tests were failing because the fetch_all method added a second check before fetching the next page. This fix will allow the next page to be retrieved when used normally --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/query.py | 8 ++++++-- test/test_pager.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index feebc1a7e..801ad4a13 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -77,6 +77,7 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] + self._pagination_item._page_number = None try: self._fetch_all() except ServerResponseError as e: @@ -85,6 +86,8 @@ def __iter__(self: Self) -> Iterator[T]: # up overrunning the total number of pages. Catch the # error and break out of the loop. raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache # If the length of the QuerySet is unknown, continue fetching until # the result cache is empty. @@ -139,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -150,7 +154,7 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: + if not self._result_cache and self._pagination_item._page_number is None: response = self.model.get(self.request_options) if isinstance(response, tuple): self._result_cache, self._pagination_item = response @@ -159,7 +163,7 @@ def _fetch_all(self: Self) -> None: self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available or sys.maxsize + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: diff --git a/test/test_pager.py b/test/test_pager.py index c30352809..1836095bb 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 From e623511f67f9952d252716e3792808760552cd67 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 17 Oct 2024 00:39:08 -0500 Subject: [PATCH 521/567] ci: cache dependencies for faster builds (#1497) * ci: cache dependencies for faster builds * ci: cache for mypy and black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 ++++++++++++++ .github/workflows/run-tests.yml | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e63..0e2b425ee 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ac622795a..2e197cf20 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,6 +18,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} From c361f8f7e0dc57da3dc6542addc843db237c506a Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:33:06 -0700 Subject: [PATCH 522/567] Feature: export custom views #999 (#1506) Adding custom views PDF & CSV export endpoints --- samples/export.py | 5 ++ .../models/custom_view_item.py | 25 +++++- .../server/endpoint/custom_views_endpoint.py | 52 +++++++++++- tableauserverclient/server/request_options.py | 80 +++++++++++-------- test/test_custom_view.py | 72 +++++++++++++++++ 5 files changed, 194 insertions(+), 40 deletions(-) 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) From 607fa8b6b1ead27bb128c9dc0d97a1c0e53b1955 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 15:59:16 -0500 Subject: [PATCH 523/567] chore: remove py2 holdover code (#1496) Favor list comprehensions for readability, consistency, and performance Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/schedules_endpoint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 4ed243b25..eec4536f9 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, From 60dfd4d293920cffeb194ae6e3e27ac5bea83694 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 22 Oct 2024 16:38:46 -0700 Subject: [PATCH 524/567] Update samples for Python 3.x compatibility (#1479) * Replace obsolete env package with os.environ * Python 2.x to 3.x updates * Fix some comments * Remove workbook data acceleration; feature was removed in 2022 * Remove switch_site() example which is confusing in this context of demonstrating login --- samples/extracts.py | 12 +- samples/login.py | 21 ++-- samples/publish_datasource.py | 23 ++-- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 ------------------- 6 files changed, 32 insertions(+), 137 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py diff --git a/samples/extracts.py b/samples/extracts.py index d21bfdd0b..c0dd885bc 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging diff --git a/samples/login.py b/samples/login.py index 847d3558f..bc99385b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 85f63fb35..c674e6882 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -118,8 +123,10 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e62..153bb0ee5 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592bc..0fe2f342c 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 57a1363ed..000000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# This script demonstrates how to update workbook data acceleration using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - - -import argparse -import logging - -import tableauserverclient as TSC -from tableauserverclient import IntervalItem - - -def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") - parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() - with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - - if all_workbooks: - # Pick 1 workbook to try data acceleration. - # Note that data acceleration has a couple of requirements, please check the Tableau help page - # to verify your workbook/view is eligible for data acceleration. - - # Assuming 1st workbook is eligible for sample purposes - sample_workbook = all_workbooks[2] - - # Enable acceleration for all the views in the workbook - enable_config = dict() - enable_config["acceleration_enabled"] = True - enable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = enable_config - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) - # Since we did not set any specific view, we will enable all views in the workbook - print("Enable acceleration for all the views in the workbook " + updated.name + ".") - - # Disable acceleration on one of the view in the workbook - # You have to populate_views first, then set the views of the workbook - # to the ones you want to update. - server.workbooks.populate_views(sample_workbook) - view_to_disable = sample_workbook.views[0] - sample_workbook.views = [view_to_disable] - - disable_config = dict() - disable_config["acceleration_enabled"] = False - disable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = disable_config - # To get the acceleration status on the response, set includeViewAccelerationStatus=true - # Note that you have to populate_views first to get the acceleration status, since - # acceleration status is per view basis (not per workbook) - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) - view1 = updated.views[0] - print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") - - # Get acceleration status of the views in workbook using workbooks.get_by_id - # This won't need to do populate_views beforehand - my_workbook = server.workbooks.get_by_id(sample_workbook.id) - view1 = my_workbook.views[0] - view2 = my_workbook.views[1] - print( - "Fetching acceleration status for views in the workbook " - + updated.name - + ".\n" - + 'View "' - + view1.name - + '" has acceleration_status = ' - + view1.data_acceleration_config["acceleration_status"] - + ".\n" - + 'View "' - + view2.name - + '" has acceleration_status = ' - + view2.data_acceleration_config["acceleration_status"] - + "." - ) - - -if __name__ == "__main__": - main() From 63ece8235a1febcb58edd881f3fa91bac52836f2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Oct 2024 18:54:42 -0500 Subject: [PATCH 525/567] chore: support VizqlDataApiAccess capability (#1504) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 186cebedd..bb3487279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -36,6 +36,7 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" From b65d8d4ab5a05d575676c5034bc40efffe425f3d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 22 Oct 2024 17:07:31 -0700 Subject: [PATCH 526/567] Remove sample code showing group name encoding (#1486) * Remove sample code showing group name encoding This is no longer needed - ran the sample and verified that it works now. --- samples/filter_sort_groups.py | 44 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index d967659ad..1694bf0f5 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = f"No project named '{filter_group_name}' found" + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() From 3e3837267bd1c2d24d3d49d1ec017755d264ed00 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 10:19:08 -0700 Subject: [PATCH 527/567] Update requests library for CVE CVE-2024-35195 (#1507) Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 67faefbe1..08f90c49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.31', # latest as at 7/31/23 + 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] From 878d5934759939a9bd79689c2b8c3c7a1cee024f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 23 Oct 2024 12:19:27 -0500 Subject: [PATCH 528/567] docs: docstrings for site item and endpoint (#1495) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/site_item.py | 66 +++++ .../server/endpoint/sites_endpoint.py | 265 ++++++++++++++++++ 2 files changed, 331 insertions(+) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 2d9f014a2..e4e146f9c 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -21,6 +21,72 @@ class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 0f3d25908..55d2a5ad0 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -15,6 +15,16 @@ class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites" @@ -22,6 +32,25 @@ def baseurl(self) -> str: # Gets all sites @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -48,6 +104,31 @@ def get_by_id(self, site_id: str) -> SiteItem: # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -77,6 +183,42 @@ def get_by_content_url(self, content_url: str) -> SiteItem: # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -100,6 +242,29 @@ def update(self, site_item: SiteItem) -> SiteItem: # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -114,6 +279,47 @@ def delete(self, site_id: str) -> None: # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -128,6 +334,25 @@ def create(self, site_item: SiteItem) -> SiteItem: @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -137,6 +362,25 @@ def encrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -146,6 +390,27 @@ def decrypt_extracts(self, site_id: str) -> None: @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) From c3ea910efbe1cfd21c5d773c1dd4faba909269ca Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 23 Oct 2024 11:51:08 -0700 Subject: [PATCH 529/567] Bring development and master into sync (#1509) From 6798b9e5fc27d459fea93b415519331c7adac954 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Wed, 23 Oct 2024 22:07:23 -0700 Subject: [PATCH 530/567] 0.34 development merge * Feature: export custom views #999 (#1506) * feat(exceptions): separate failed signin error (#1478) * refactor request_options, add language param (#1481) * Set FILESIZE_LIMIT_MB via environment variables (#1466) * added PulseMetricDefine cap (#1490) * Adding project permissions handling for databases, tables and virtual connections (#1482) * fix: queryset support for flowruns (#1460) * fix: set unknown size to sys.maxsize * fix: handle 0 item response in querysets (#1501) * chore: support VizqlDataApiAccess capability (#1504) * refactor(test): extract error factory to _utils * chore(typing): flowruns.cancel can also accept a FlowRunItem * chore: type hint default permissions endpoints (#1493) * chore(versions): update remaining f-strings (#1477) * Make urllib3 dependency more flexible (#1468) * Update requests library for CVE CVE-2024-35195 (#1507) * chore(versions): Upgrade minimum python version (#1465) * ci: cache dependencies for faster builds (#1497) * ci: build on python 3.13 (#1492) * Update samples for Python 3.x compatibility (#1479) * chore: remove py2 holdover code (#1496) * #Add 'description' to datasource sample code (#1475) * Remove sample code showing group name encoding (#1486) * chore(typing): include samples in type checks (#1455) * fix: docstring on QuerySet * docs: add docstrings to auth objects and endpoints (#1484) * docs: docstrings for Server and ServerInfo (#1494) * docs: docstrings for user item and endpoint (#1485) * docs: docstrings for site item and endpoint (#1495) * docs: workbook docstrings (#1488) * #1464 - docs update for filtering on boolean values (#1471) --------- Co-authored-by: Brian Cantoni Co-authored-by: Jordan Woods Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac Co-authored-by: Henning Merklinger Co-authored-by: AlbertWangXu Co-authored-by: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> --- .github/workflows/meta-checks.yml | 14 + .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 --- .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 ++- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 ++- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 ++- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 ++- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +++++++- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +++++++++- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++++++++++++++++-- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 ++- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 +++---- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 + test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 ++ test/test_auth.py | 6 +- test/test_custom_view.py | 72 ++ test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 - test/test_job.py | 8 +- test/test_pager.py | 12 + test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 + test/test_site_model.py | 2 - test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 3347 insertions(+), 1407 deletions(-) delete mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 41a944e63..0e2b425ee 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,6 +13,20 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d70539582..2e197cf20 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,11 +13,25 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] runs-on: ${{ matrix.os }} steps: + - name: Get pip cache dir + id: pip-cache + shell: bash + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + + - name: cache + uses: actions/cache@v4 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.python-version }}-pip- + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 3bf47ea23..08f90c49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.31', # latest as at 7/31/23 - 'urllib3==2.2.2', # dependabot + 'requests>=2.32', # latest as at 7/31/23 + 'urllib3>=2.2.2,<3', 'typing_extensions>=4.0.1', ] -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] [tool.mypy] check_untyped_defs = false disable_error_code = [ 'misc', - # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] -files = ["tableauserverclient", "test"] +files = ["tableauserverclient", "test", "samples"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true +implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index 5a450e8ab..d26d009e2 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index f4c6a9ca9..aca3e895b 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,7 +11,6 @@ import os from datetime import time -from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -63,23 +62,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print("Add users to site from file {}:".format(filepath)) - added: List[TSC.UserItem] - failed: List[TSC.UserItem, TSC.ServerResponseError] + print(f"Add users to site from file {filepath}:") + added: list[TSC.UserItem] + failed: list[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print("Adding users to group:{}".format(added)) + print(f"Adding users to group:{added}") for user in added: - print("Adding user {}".format(user)) + print(f"Adding user {user}") try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print("user {} is already a member of group {}".format(user.name, group.name)) + print(f"user {user.name} is already a member of group {group.name}") else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index 1fc649f8c..d775902aa 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print("Permissions from project {}:".format(changed_project.id)) + print(f"Permissions from project {changed_project.id}:") print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index dee088571..c23a2eced 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + print(f"Hourly schedule created (ID: {hourly_schedule.id}).") except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + print(f"Daily schedule created (ID: {daily_schedule.id}).") except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + print(f"Weekly schedule created (ID: {weekly_schedule.id}).") except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) + print(f"Monthly schedule created (ID: {monthly_schedule.id}).") except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index fb45cb45e..c9f35d5be 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,16 +51,17 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) + new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print("Datasource published. ID: {}".format(new_datasource.id)) + print(f"Datasource published. ID: {new_datasource.id}") else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} datasources on site: ") print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -69,20 +70,19 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print("\nConnections for {}: ".format(sample_datasource.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_datasource.connections - ] - ) + print(f"\nConnections for {sample_datasource.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) + + # Demonstrate that description is editable + sample_datasource.description = "Description updated by TSC" + server.datasources.update(sample_datasource) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print("\nOld tag set: {}".format(original_tag_set)) - print("New tag set: {}".format(sample_datasource.tags)) + print(f"\nOld tag set: {original_tag_set}") + print(f"New tag set: {sample_datasource.tags}") # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index 243e91954..f199522ed 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient import Resource +from tableauserverclient.models import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print("Favorites for user: {}".format(user.id)) + print(f"Favorites for user: {user.id}") server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook: TSC.WorkbookItem = all_workbook_items[0] - server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) + my_workbook = all_workbook_items[0] + server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,12 +70,10 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print( - "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) - ) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") server.favorites.delete_favorite_view(user, my_view) - print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index a2274f1a7..eb9eba0de 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print("Delete site `{}`?".format(current_site.name)) + print(f"Delete site `{current_site.name}`?") # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index 77802b1db..f25c41849 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print("Webhook created. ID: {}".format(new_webhook.id)) + print(f"Webhook created. ID: {new_webhook.id}") # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} webhooks on site: ") print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 57f88aa07..f51639ab3 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print("Workbook published. ID: {}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,27 +78,22 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print("\nName of views in {}: ".format(sample_workbook.name)) + print(f"\nName of views in {sample_workbook.name}: ") print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print("\nConnections for {}: ".format(sample_workbook.name)) - print( - [ - "{0}({1})".format(connection.id, connection.datasource_name) - for connection in sample_workbook.connections - ] - ) + print(f"\nConnections for {sample_workbook.name}: ") + print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print("\nWorkbook's old tag set: {}".format(original_tag_set)) - print("Workbook's new tag set: {}".format(sample_workbook.tags)) - print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) + print(f"\nWorkbook's old tag set: {original_tag_set}") + print(f"Workbook's new tag set: {sample_workbook.tags}") + print(f"Workbook tabbed: {sample_workbook.show_tabs}") # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -109,8 +104,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print("\nView's old tag set: {}".format(original_tag_set)) - print("View's new tag set: {}".format(sample_view.tags)) + print(f"\nView's old tag set: {original_tag_set}") + print(f"View's new tag set: {sample_view.tags}") # Delete tag from just one view sample_view.tags = original_tag_set @@ -119,14 +114,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print("\nDownloaded workbook to {}".format(path)) + print(f"\nDownloaded workbook to {path}") if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) + print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") # get custom views cvs, _ = server.custom_views.get() @@ -153,10 +148,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print("deleting {}".format(c.id)) + print(f"deleting {c.id}") unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index f2783fa6e..b2506cf46 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,8 +37,11 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - + parser.add_argument( + "--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") @@ -56,14 +59,16 @@ 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) if not item: - print("No item found for id {}".format(args.resource_id)) + print(f"No item found for id {args.resource_id}") exit(1) - print("Item found: {}".format(item.name)) + print(f"Item found: {item.name}") # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -72,18 +77,22 @@ 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() if args.filter: - options = option_factory().vf(*args.filter.split(":")) - else: - options = None + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language if args.file: filename = args.file else: - filename = "out.{}".format(extension) + filename = f"out-{options.language}.{extension}" populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index 9bd87a473..c0dd885bc 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,13 +1,7 @@ #### -# This script demonstrates how to use the Tableau Server Client -# to interact with workbooks. It explores the different -# functions that the Server API supports on workbooks. -# -# With no flags set, this sample will query all workbooks, -# pick one workbook and populate its connections/views, and update -# the workbook. Adding flags will demonstrate the specific feature -# on top of the general operations. -#### +# This script demonstrates how to use the Tableau Server Client to interact with extracts. +# It explores the different functions that the REST API supports on extracts. +##### import argparse import logging @@ -47,7 +41,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 042af32e2..1694bf0f5 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,37 +57,36 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # URL Encode the name of the group that we want to filter on - # i.e. turn spaces into plus signs - filter_group_name = urllib.parse.quote_plus(group_name) + # we no longer need to encode the space options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) - ) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group_name = filtered_groups.pop().name - print(group_name) + group = filtered_groups.pop() + print(group) else: - error = "No project named '{}' found".format(filter_group_name) + error = f"No group named '{group_name}' found" print(error) + print("---") + # Or, try the above with the django style filtering try: - group = server.groups.filter(name=filter_group_name)[0] + group = server.groups.filter(name=group_name)[0] + print(group) except IndexError: - print(f"No project named '{filter_group_name}' found") - else: - print(group.name) + print(f"No group named '{group_name}' found") + + print("====") options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], + ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], ) ) @@ -98,13 +97,20 @@ def main(): for group in matching_groups: print(group.name) + print("----") # or, try the above with the django style filtering. - - groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] - groups = [urllib.parse.quote_plus(group) for group in groups] - for group in server.groups.filter(name__in=groups).sort("-name"): + all_g = server.groups.all() + print(f"Searching locally among {all_g.total_available} groups") + for a in all_g: + print(a) + groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] + print(groups) + + for group in server.groups.filter(name__in=groups).order_by("-name"): print(group.name) + print("done") + if __name__ == "__main__": main() diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 7aa62a5c1..6c3a85dcd 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = "No project named '{}' found".format(filter_project_name) + error = f"No project named '{filter_project_name}' found" print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 454b225de..5f8cfa238 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print("Connected to {}".format(server.server_info.baseurl)) - print("Server information: {}".format(server.server_info)) + print(f"Connected to {server.server_info.baseurl}") + print(f"Server information: {server.server_info}") print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index d62896059..8635947a8 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in the url # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index 21de97831..a2c4301d0 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print("Connected to {}".format(server.server_info.baseurl)) + print(f"Connected to {server.server_info.baseurl}") # 3 - replace with your site name exactly as it looks in a url # e.g https://my-server/#/this-is-your-site-url-name/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print("{} projects".format(pagination.total_available)) + print(f"{pagination.total_available} projects") for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print("{} workbooks".format(pagination.total_available)) + print(f"{pagination.total_available} workbooks") print(workbooks[0]) views, pagination = server.views.get() if views: - print("{} views".format(pagination.total_available)) + print(f"{pagination.total_available} views") print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print("{} datasources".format(pagination.total_available)) + print(f"{pagination.total_available} datasources") print(datasources[0]) # I think all these other content types can go to a hello_universe script # data alert, dqw, flow, ... do any of these require any add-ons? jobs, pagination = server.jobs.get() if jobs: - print("{} jobs".format(pagination.total_available)) + print(f"{pagination.total_available} jobs") print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print("{} schedules".format(pagination.total_available)) + print(f"{pagination.total_available} schedules") print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print("{} tasks".format(pagination.total_available)) + print(f"{pagination.total_available} tasks") print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print("{} webhooks".format(pagination.total_available)) + print(f"{pagination.total_available} webhooks") print(webhooks[0]) users, pagination = server.users.get() if users: - print("{} users".format(pagination.total_available)) + print(f"{pagination.total_available} users") print(users[0]) groups, pagination = server.groups.get() if groups: - print("{} groups".format(pagination.total_available)) + print(f"{pagination.total_available} groups") print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cb3d9e1d0..cdfaf27a8 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print("Site not found: {0} Creating it...".format(args.site_id)) + print(f"Site not found: {args.site_id} Creating it...") new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print("Site {0} exists. Moving on...".format(args.site_id)) + print(f"Site {args.site_id} exists. Moving on...") ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print("Project not found: {0} Creating it...".format(args.project)) + print(f"Project not found: {args.project} Creating it...") new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print("Datasource published. ID: {0}".format(new_ds.id)) + print(f"Datasource published. ID: {new_ds.id}") def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 8d72fb620..2675a2954 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,6 +48,9 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) + if endpoint is None: + print("Resource type not found.") + sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -59,7 +62,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print("Total: {}".format(count)) + print(f"Total: {count}") if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index 6a3e9e8b3..bc99385b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,9 +7,15 @@ import argparse import getpass import logging +import os import tableauserverclient as TSC -import env + + +def get_env(key): + if key in os.environ: + return os.environ[key] + return None # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -20,13 +26,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging_level = "debug" server = sample_connect_to_server(args) @@ -59,7 +65,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") else: # Trying to authenticate using personal access tokens. @@ -68,7 +74,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -79,10 +85,7 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "2.6" - new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) - server.auth.switch_site(new_site) - print("Logged in successfully") + server.version = "3.19" return server diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index 47af1f2f9..e82c75cf9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print("No workbook named {} found.".format(args.workbook_name)) + print(f"No workbook named {args.workbook_name} found.") else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) + True for site in all_sites if args.destination_site.lower() == site.content_url.lower() ) if not found_destination_site: - error = "No site named {} found.".format(args.destination_site) + error = f"No site named {args.destination_site} found." raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) + print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a7ae6dc89..a68eed4b3 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Total: {}\n".format(count)) + print(f"Total: {count}\n") count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print("Truncated Total: {}\n".format(count)) + print(f"Truncated Total: {count}\n") print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print("Filtered Total: {}\n".format(count)) + print(f"Filtered Total: {count}\n") # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print("QuerySet Total: {}".format(count)) + print(f"QuerySet Total: {count}") # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index 5ac768674..c674e6882 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,12 +21,17 @@ import argparse import logging +import os import tableauserverclient as TSC - -import env import tableauserverclient.datetime_helpers +def get_env(key): + if key in os.environ: + return os.environ[key] + return None + + def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") # Common options; please keep those in sync across all samples @@ -52,13 +57,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = env.server + args.server = get_env("SERVER") if not args.site: - args.site = env.site + args.site = get_env("SITE") if not args.token_name: - args.token_name = env.token_name + args.token_name = get_env("TOKEN_NAME") if not args.token_value: - args.token_value = env.token_value + args.token_value = get_env("TOKEN_VALUE") args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -111,15 +116,17 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) + print(f"Datasource published asynchronously. Job ID: {new_job.id}") else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - "{0}Datasource published. Datasource ID: {1}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ( + "{}Datasource published. Datasource ID: {}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() + ) ) ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 8a9f45279..d31978c0f 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. JOB ID: {0}".format(new_job.id)) + print(f"Workbook published. JOB ID: {new_job.id}") else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print("Workbook published. ID: {0}".format(new_workbook.id)) + print(f"Workbook published. ID: {new_workbook.id}") else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 4e509cd97..3309acd90 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,17 +57,15 @@ def main(): permissions = resource.permissions # Print result - print( - "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) - ) + print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) + print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") for capability in capabilities: - print("\t{0} - {1}".format(capability, capabilities[capability])) + print(f"\t{capability} - {capabilities[capability]}") if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index 03daedf16..c95000898 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print("{}".format(task)) + print(f"{task}") def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print("{}".format(task)) + print(f"{task}") def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 56fd12e62..153bb0ee5 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in kwargs.items(): + for item, value in list(kwargs.items()): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 4af6592bc..0fe2f342c 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) + connections = list([x for x in resource.connections if x.id == args.connection_id]) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py deleted file mode 100644 index 75f12262f..000000000 --- a/samples/update_workbook_data_acceleration.py +++ /dev/null @@ -1,109 +0,0 @@ -#### -# This script demonstrates how to update workbook data acceleration using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - - -import argparse -import logging - -import tableauserverclient as TSC -from tableauserverclient import IntervalItem - - -def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-S", help="site name") - parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") - parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") - parser.add_argument( - "--logging-level", - "-l", - choices=["debug", "info", "error"], - default="error", - help="desired logging level (set to error by default)", - ) - # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here - - args = parser.parse_args() - - # Set logging level based on user input, or error by default - logging_level = getattr(logging, args.logging_level.upper()) - logging.basicConfig(level=logging_level) - - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() - with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) - print([workbook.name for workbook in all_workbooks]) - - if all_workbooks: - # Pick 1 workbook to try data acceleration. - # Note that data acceleration has a couple of requirements, please check the Tableau help page - # to verify your workbook/view is eligible for data acceleration. - - # Assuming 1st workbook is eligible for sample purposes - sample_workbook = all_workbooks[2] - - # Enable acceleration for all the views in the workbook - enable_config = dict() - enable_config["acceleration_enabled"] = True - enable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = enable_config - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) - # Since we did not set any specific view, we will enable all views in the workbook - print("Enable acceleration for all the views in the workbook " + updated.name + ".") - - # Disable acceleration on one of the view in the workbook - # You have to populate_views first, then set the views of the workbook - # to the ones you want to update. - server.workbooks.populate_views(sample_workbook) - view_to_disable = sample_workbook.views[0] - sample_workbook.views = [view_to_disable] - - disable_config = dict() - disable_config["acceleration_enabled"] = False - disable_config["accelerate_now"] = True - - sample_workbook.data_acceleration_config = disable_config - # To get the acceleration status on the response, set includeViewAccelerationStatus=true - # Note that you have to populate_views first to get the acceleration status, since - # acceleration status is per view basis (not per workbook) - updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) - view1 = updated.views[0] - print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") - - # Get acceleration status of the views in workbook using workbooks.get_by_id - # This won't need to do populate_views beforehand - my_workbook = server.workbooks.get_by_id(sample_workbook.id) - view1 = my_workbook.views[0] - view2 = my_workbook.views[1] - print( - "Fetching acceleration status for views in the workbook " - + updated.name - + ".\n" - + 'View "' - + view1.name - + '" has acceleration_status = ' - + view1.data_acceleration_config["acceleration_status"] - + ".\n" - + 'View "' - + view2.name - + '" has acceleration_status = ' - + view2.data_acceleration_config["acceleration_status"] - + "." - ) - - -if __name__ == "__main__": - main() diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index 9e4d63dc1..c23e3717f 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index bab2cf05f..e0a7abb64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,11 +32,13 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, + TableauItem, TableItem, TableauAuth, Target, @@ -56,6 +58,7 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, + FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -65,65 +68,68 @@ ) __all__ = [ - "get_versions", - "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", + "CSVRequestOptions", "CustomViewItem", - "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", + "DEFAULT_NAMESPACE", + "DQWItem", + "ExcelRequestOptions", + "FailedSignInError", "FavoriteItem", + "FileuploadItem", + "Filter", "FlowItem", "FlowRunItem", - "FileuploadItem", + "get_versions", "GroupItem", "GroupSetItem", "HourlyInterval", + "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", + "LinkedTaskFlowRunItem", + "LinkedTaskItem", + "LinkedTaskStepItem", "MetricItem", + "MissingRequiredFieldError", "MonthlyInterval", + "NotSignedInError", + "Pager", "PaginationItem", + "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RequestOptions", + "Resource", "RevisionItem", "ScheduleItem", - "SiteItem", + "Server", "ServerInfoItem", + "ServerResponseError", + "SiteItem", + "Sort", "SubscriptionItem", - "TableItem", "TableauAuth", + "TableauItem", + "TableItem", "Target", "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", - "CSVRequestOptions", - "ExcelRequestOptions", - "ImageRequestOptions", - "PDFRequestOptions", - "RequestOptions", - "MissingRequiredFieldError", - "NotSignedInError", - "ServerResponseError", - "Filter", - "Pager", - "Server", - "Sort", - "LinkedTaskItem", - "LinkedTaskStepItem", - "LinkedTaskFlowRunItem", - "VirtualConnectionItem", ] diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index d47374097..79dbed1d8 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}") return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( + pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 63872398f..a75112754 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,11 +6,13 @@ DELAY_SLEEP_SECONDS = 0.1 -# The maximum size of a file that can be published in a single request is 64MB -FILESIZE_LIMIT_MB = 64 - class Config: + # The maximum size of a file that can be published in a single request is 64MB + @property + def FILESIZE_LIMIT_MB(self): + return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index df936e315..3a7416e28 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem(object): +class ColumnItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index d61bbb751..bb2cbbba9 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials(object): +class ConnectionCredentials: """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 62ff530c9..937e43481 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem(object): +class ConnectionItem: def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) + f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> List["ConnectionItem"]: + def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ - all_connection_items: List["ConnectionItem"] = list() + all_connection_items: list["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 246a19e7f..a0c0a9844 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,8 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -11,12 +12,14 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem(object): +class CustomViewItem: def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = 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 @@ -35,11 +38,17 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return "".format(self.id, self.name, view_info, wb_info, owner_info) + return f"" 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 @@ -104,7 +127,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -121,7 +144,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 7424e6b95..3a8883bed 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem(object): - class ComparisonRecord(object): +class DataAccelerationReportItem: + class ComparisonRecord: def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 65be233e3..7285ee609 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem(object): +class DataAlertItem: class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[List[str]] = None + self._recipients: Optional[list[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> List[str]: + def recipients(self) -> list[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> List["DataAlertItem"]: + def from_response(cls, resp, ns) -> list["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index f567c501c..6e0cb9001 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional, Union, List +from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[List[str]] = interval_item + self.interval_item: Optional[list[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[List[str]]: + def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: List[str]): + def interval_item(self, value: list[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + error = f"Invalid interval value for a monthly frequency: {interval_values}." # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] # First check if the list only have LastDay value. When using LastDay, there shouldn't be diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index dfc58e1bb..4d4604461 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem(object): +class DatabaseItem: class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return "".format(self._id, self.name) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index e4e71c4a2..1b082c157 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, Set, Tuple +from typing import Optional from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem(object): +class DatasourceItem: class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: Set = set() + self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[List[ConnectionItem]]: + def connections(self) -> Optional[list[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[List[PermissionsRule]]: + def permissions(self) -> Optional[list[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: + def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: + def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index ada041481..fbda9d9f2 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem(object): +class DQWItem: class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index caff755e3..4fea280f7 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,28 +1,27 @@ import logging +from typing import Union from defusedxml.ElementTree import fromstring -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem -from typing import Dict, List from tableauserverclient.helpers.logging import logger -from typing import Dict, List, Union -FavoriteType = Dict[ +FavoriteType = dict[ str, - List[TableauItem], + list[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: + def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index e9bdd25b2..aea4dfe1f 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem(object): +class FileuploadItem: def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index edce2ec97..9bcad5e89 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import List, Optional, Set +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem(object): +class FlowItem: def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: Set[str] = set() + self.tags: set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> List["FlowItem"]: + def from_response(cls, resp, ns) -> list["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index 12281f4f8..f2f1d561f 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Dict, List, Optional, Type +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem(object): +class FlowRunItem: def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6c8f7eb01..6871f8b16 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TYPE_CHECKING +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem(object): +class GroupItem: tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return "{}({!r})".format(self.__class__.__name__, self.__dict__) + return f"{self.__class__.__name__}({self.__dict__!r})" @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> List["GroupItem"]: + def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ffb57adf5..aa653a79e 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: List["GroupItem"] = [] + self.groups: list["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 444674e19..d7cf891cc 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem(object): +class IntervalItem: class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval(object): +class HourlyInterval: def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval(object): +class DailyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = "Invalid weekDay interval {}".format(interval) + error = f"Invalid weekDay interval {interval}" raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval(object): +class WeeklyInterval: def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval(object): +class MonthlyInterval: def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 155ce668b..cc7cd5811 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem(object): +class JobItem: class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[List[str]] = None, + notes: Optional[list[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: List[str] = notes or [] + self._notes: list[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> List[str]: + def notes(self) -> list[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> List["JobItem"]: + def from_response(cls, xml, ns) -> list["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem(object): +class BackgroundJobItem: class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index ae9b60425..14a0e4978 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: List[LinkedTaskFlowRunItem] = [] + self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index d8ba8e825..432fd861a 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import List, Optional, Set +from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem(object): +class MetricItem: def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: Set[str] = set() - self.tags: Set[str] = set() + self._initial_tags: set[str] = set() + self.tags: set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> List["MetricItem"]: + ) -> list["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 8cebd1c86..f30519be5 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem(object): +class PaginationItem: def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 26f4ee7e8..bb3487279 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Dict, List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -36,23 +36,25 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" + VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" + PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return "".format(self.grantee, self.capabilities) + return f"" def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -66,7 +68,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -86,7 +88,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} new_capabilities = {} for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): @@ -100,14 +102,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: Dict[str, str] = {} + capability_dict: dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -116,7 +118,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error("Capability was not valid: {}".format(capability_xml)) + logger.error(f"Capability was not valid: {capability_xml}") raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -127,7 +129,7 @@ def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -146,6 +148,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) + raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9fb382885..48f27c60c 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,14 +8,16 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem(object): +class ProjectItem: + ERROR_MSG = "Project item must be populated with permissions first." + class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -43,6 +45,9 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None + self._default_virtualconnection_permissions = None + self._default_database_permissions = None + self._default_table_permissions = None @property def content_permissions(self): @@ -56,52 +61,63 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - error = "Project item must be populated with permissions first." - raise UnpopulatedPropertyError(error) + raise UnpopulatedPropertyError(self.ERROR_MSG) return self._default_metric_permissions() + @property + def default_virtualconnection_permissions(self): + if self._default_virtualconnection_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_virtualconnection_permissions() + + @property + def default_database_permissions(self): + if self._default_database_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_database_permissions() + + @property + def default_table_permissions(self): + if self._default_table_permissions is None: + raise UnpopulatedPropertyError(self.ERROR_MSG) + return self._default_table_permissions() + @property def id(self) -> Optional[str]: return self._id @@ -158,7 +174,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = "_default_{content}_permissions".format(content=content_type) + attr = f"_default_{content_type}_permissions" setattr( self, attr, @@ -166,7 +182,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> List["ProjectItem"]: + def from_response(cls, resp, ns) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ce31b1428..5048b3498 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,8 @@ import datetime import re from functools import wraps -from typing import Any, Container, Optional, Tuple +from typing import Any, Optional +from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -11,7 +12,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) + error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." raise ValueError(error) return func(self, value) @@ -24,7 +25,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = "Boolean expected for {0} flag.".format(func.__name__) + error = f"Boolean expected for {func.__name__} flag." raise ValueError(error) return func(self, value) @@ -35,7 +36,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = "{0} must be defined.".format(func.__name__) + error = f"{func.__name__} must be defined." raise ValueError(error) return func(self, value) @@ -46,7 +47,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = "{0} must not be empty.".format(func.__name__) + error = f"{func.__name__} must not be empty." raise ValueError(error) return func(self, value) @@ -66,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -81,7 +82,7 @@ def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = "Invalid property defined: '{}'. Integer value expected.".format(value) + error = f"Invalid property defined: '{value}'. Integer value expected." if range is None: if isinstance(value, int): @@ -133,7 +134,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) + f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" ) dt = parse_datetime(value) @@ -146,11 +147,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) + raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = "{} should have 2 keys ".format(func.__name__) + error = f"{func.__name__} should have 2 keys " error += "'acceleration_enabled' and 'accelerate_now'" - error += "instead you have {}".format(value.keys()) + error += f"instead you have {value.keys()}" raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 710548fcc..4c1fff564 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference(object): +class ResourceReference: def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return "".format(self._id, self._tag_name) + return f"" __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index a0e6a1bd5..1b4cc6249 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem(object): +class RevisionItem: def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e416643ba..e39042058 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem(object): +class ScheduleItem: class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - "Status {}: {}".format(response.status_code, response.reason) + f"Status {response.status_code}: {response.reason}" if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index 57fc51af9..b13f26740 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,7 +6,29 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem(object): +class ServerInfoItem: + """ + The ServerInfoItem class contains the build and version information for + Tableau Server. The server information is accessed with the + server_info.get() method, which returns an instance of the ServerInfo class. + + Attributes + ---------- + product_version : str + Shows the version of the Tableau Server or Tableau Cloud + (for example, 10.2.0). + + build_number : str + Shows the specific build number (for example, 10200.17.0329.1446). + + rest_api_version : str + Shows the supported REST API version number. Note that this might be + different from the default value specified for the server, with the + Server.version attribute. To take advantage of new features, you should + query the server and set the Server.version to match the supported REST + API version number. + """ + def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -40,13 +62,11 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) + logger.exception(f"Unexpected response for ServerInfo: {resp}") return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.info("Unexpected response for ServerInfo: {}".format(resp)) - logger.info(error) - return cls("Unknown", "Unknown", "Unknown") + logger.exception(f"Unexpected response for ServerInfo: {resp}") + raise error product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index b651e5773..e4e146f9c 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,13 +14,79 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import List, Optional, Union, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem(object): +class SiteItem: + """ + The SiteItem class contains the members or attributes for the site resources + on Tableau Server or Tableau Cloud. The SiteItem class defines the + information you can request or query from Tableau Server or Tableau Cloud. + The class members correspond to the attributes of a server request or + response payload. + + Attributes + ---------- + name: str + The name of the site. The name of the default site is "". + + content_url: str + The path to the site. + + admin_mode: str + (Optional) For Tableau Server only. Specify ContentAndUsers to allow + site administrators to use the server interface and tabcmd commands to + add and remove users. (Specifying this option does not give site + administrators permissions to manage users using the REST API.) Specify + ContentOnly to prevent site administrators from adding or removing + users. (Server administrators can always add or remove users.) + + user_quota: int + (Optional) Specifies the total number of users for the site. The number + can't exceed the number of licenses activated for the site; and if + tiered capacity attributes are set, then user_quota will equal the sum + of the tiered capacity values, and attempting to set user_quota will + cause an error. + + tier_explorer_capacity: int + tier_creator_capacity: int + tier_viewer_capacity: int + (Optional) The maximum number of licenses for users with the Creator, + Explorer, or Viewer role, respectively, allowed on a site. + + storage_quota: int + (Optional) Specifies the maximum amount of space for the new site, in + megabytes. If you set a quota and the site exceeds it, publishers will + be prevented from uploading new content until the site is under the + limit again. + + disable_subscriptions: bool + (Optional) Specify true to prevent users from being able to subscribe + to workbooks on the specified site. The default is False. + + subscribe_others_enabled: bool + (Optional) Specify false to prevent server administrators, site + administrators, and project or content owners from being able to + subscribe other users to workbooks on the specified site. The default + is True. + + revision_history_enabled: bool + (Optional) Specify true to enable revision history for content resources + (workbooks and datasources). The default is False. + + revision_limit: int + (Optional) Specifies the number of revisions of a content source + (workbook or data source) to allow. On Tableau Server, the default is + 25. + + state: str + Shows the current state of the site (Active or Suspended). + + """ + _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -873,7 +939,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> List["SiteItem"]: + def from_response(cls, resp, ns) -> list["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e96fcc448..61c75e2d6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import List, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem(object): +class SubscriptionItem: def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index f9df8a8f3..0afdd4df3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem(object): +class TableItem: def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 10cf58723..7d7981433 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Dict, Optional +from typing import Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: credentials = ( "Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request" @@ -32,6 +32,43 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): + """ + The TableauAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + user name, password, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + username : str + The user name for the sign-in request. + + password : str + The password for the sign-in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -42,7 +79,7 @@ def __init__( self.username = username @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -55,6 +92,43 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): + """ + The PersonalAccessTokenAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + token name, token secret, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token_name : str + The name of the personal access token. + + personal_access_token : str + The personal access token secret for the sign in request. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__( self, token_name: str, @@ -69,7 +143,7 @@ def __init__( self.personal_access_token = personal_access_token @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -88,6 +162,42 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): + """ + The JWTAuth class defines the information you can set in a sign-in + request. The class members correspond to the attributes of a server request + or response payload. To use this class, create a new instance, supplying + an encoded JSON Web Token, and site information if necessary, and pass the + request object to the Auth.sign_in method. + + Parameters + ---------- + token : str + The encoded JSON Web Token. + + site_id : str, optional + This corresponds to the contentUrl attribute in the Tableau REST API. + The site_id is the portion of the URL that follows the /site/ in the + URL. For example, "MarketingTeam" is the site_id in the following URL + MyServer/#/site/MarketingTeam/projects. To specify the default site on + Tableau Server, you can use an empty string '' (single quotes, no + space). For Tableau Cloud, you must provide a value for the site_id. + + user_id_to_impersonate : str, optional + Specifies the id (not the name) of the user to sign in as. This is not + available for Tableau Online. + + Examples + -------- + >>> import jwt + >>> import tableauserverclient as TSC + + >>> jwt_token = jwt.encode(...) + >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') + >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) + >>> server.auth.sign_in(tableau_auth) + + """ + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") @@ -95,7 +205,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> Dict[str, str]: + def credentials(self) -> dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index bac072076..01ee3d3a9 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Resource) -> str: +def plural_type(content_type: Union[Resource, str]) -> str: if content_type == Resource.Lens: return "lenses" else: - return "{}s".format(content_type) + return f"{content_type}s" diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index afa0a0762..cde755f05 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,16 +1,15 @@ import xml.etree.ElementTree as ET -from typing import Set from defusedxml.ElementTree import fromstring -class TagItem(object): +class TagItem: @classmethod - def from_response(cls, resp: bytes, ns) -> Set[str]: + def from_response(cls, resp: bytes, ns) -> set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 01cfcfb11..fa6f782ba 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional +from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem(object): +class TaskItem: class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) + all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index fe659575a..365e44c1d 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Dict, List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -18,10 +18,35 @@ from tableauserverclient.server import Pager -class UserItem(object): +class UserItem: + """ + The UserItem class contains the members or attributes for the view + resources on Tableau Server. The UserItem class defines the information you + can request or query from Tableau Server. The class attributes correspond + to the attributes of a server request or response payload. + + + Parameters + ---------- + name: str + The name of the user. + + site_role: str + The role of the user on the site. + + auth_setting: str + Required attribute for Tableau Cloud. How the user autenticates to the + server. + """ + tag_name: str = "user" class Roles: + """ + The Roles class contains the possible roles for a user on Tableau + Server. + """ + Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -43,6 +68,11 @@ class Roles: SupportUser = "SupportUser" class Auth: + """ + The Auth class contains the possible authentication settings for a user + on Tableau Cloud. + """ + OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" @@ -57,7 +87,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[Dict[str, List]] = None + self._favorites: Optional[dict[str, list]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -69,7 +99,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return "".format(self.id, self.name, str_site_role) + return f"" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -141,7 +171,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> Dict[str, List]: + def favorites(self) -> dict[str, list]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -210,12 +240,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> List["UserItem"]: + def from_response(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -283,7 +313,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport(object): + class CSVImport: """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -308,7 +338,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: List[str] = list(map(str.strip, line.split(","))) + values: list[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -337,7 +367,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -345,11 +375,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L while line and line != "": try: # do not print passwords - logger.info("Reading user {}".format(line[:4])) + logger.info(f"Reading user {line[:4]}") UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info("Error parsing {}: {}".format(line[:4], exc)) + logger.info(f"Error parsing {line[:4]}: {exc}") invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -358,7 +388,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, L # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: List[List[str]] = [ + _valid_attributes: list[list[str]] = [ [], [], [], @@ -373,23 +403,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug("> details - {}".format(username)) + logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError("Invalid value {} for {}".format(item, column_type)) + raise AttributeError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index a26e364a3..dc5f37a48 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,8 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Iterator, List, Optional, Set +from typing import Callable, Optional +from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -11,13 +12,13 @@ from .tag_item import TagItem -class ViewItem(object): +class ViewItem: def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: Set[str] = set() + self._initial_tags: set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -29,15 +30,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None - self.tags: Set[str] = set() + self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None + self.tags: set[str] = set() self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -146,21 +147,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 76a3b5dea..e9e22be1e 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,7 @@ import datetime as dt import json -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Optional +from collections.abc import Iterable from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -23,7 +24,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[Dict[str, dict]] = None + self.content: Optional[dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -40,7 +41,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -53,12 +54,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index e4d5e4aa0..98d821fb4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import List, Optional, Tuple, Type +from typing import Optional from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem(object): +class WebhookItem: def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = "webhook-source-event-{}".format(value) + self._event = f"webhook-source-event-{value}" @classmethod - def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: + def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return "".format(self.id, self.name, self.url, self.event) + return f"" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 58fd2a9a9..776d041e3 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Optional from defusedxml.ElementTree import fromstring @@ -20,7 +20,85 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem(object): +class WorkbookItem: + """ + The workbook resources for Tableau are defined in the WorkbookItem class. + The class corresponds to the workbook resources you can access using the + Tableau REST API. Some workbook methods take an instance of the WorkbookItem + class as arguments. The workbook item specifies the project. + + Parameters + ---------- + project_id : Optional[str], optional + The project ID for the workbook, by default None. + + name : Optional[str], optional + The name of the workbook, by default None. + + show_tabs : bool, optional + Determines whether the workbook shows tabs for the view. + + Attributes + ---------- + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the data sources used + by the workbook. You must first call the workbooks.populate_connections + method to access this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the workbook as it appears in the URL. + + created_at : Optional[datetime.datetime] + The date and time the workbook was created. + + description : Optional[str] + User-defined description of the workbook. + + id : Optional[str] + The identifier for the workbook. You need this value to query a specific + workbook or to delete a workbook with the get_by_id and delete methods. + + owner_id : Optional[str] + The identifier for the owner (UserItem) of the workbook. + + preview_image : bytes + The thumbnail image for the view. You must first call the + workbooks.populate_preview_image method to access this data. + + project_name : Optional[str] + The name of the project that contains the workbook. + + size: int + The size of the workbook in megabytes. + + hidden_views: Optional[list[str]] + List of string names of views that need to be hidden when the workbook + is published. + + tags: set[str] + The set of tags associated with the workbook. + + updated_at : Optional[datetime.datetime] + The date and time the workbook was last updated. + + views : list[ViewItem] + The list of views (ViewItem) for the workbook. You must first call the + workbooks.populate_views method to access this data. See the ViewItem + class. + + web_page_url : Optional[str] + The full URL for the workbook. + + Examples + -------- + # creating a new instance of a WorkbookItem + >>> import tableauserverclient as TSC + + >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' + + >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') + """ + def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -35,15 +113,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], List[ViewItem]]] = None + self._views: Optional[Callable[[], list[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[List[str]] = None - self.tags: Set[str] = set() + self.hidden_views: Optional[list[str]] = None + self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -56,7 +134,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -64,14 +142,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> List[ConnectionItem]: + def connections(self) -> list[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> List[PermissionsRule]: + def permissions(self) -> list[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -152,7 +230,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> List[ViewItem]: + def views(self) -> list[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -191,7 +269,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> List[RevisionItem]: + def revisions(self) -> list[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -203,7 +281,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -316,7 +394,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: + def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index d225ecff6..54ac46d8d 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace(object): +class Namespace: def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index f5cd1d236..87cc9460b 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,6 +57,7 @@ "Sort", "Server", "Pager", + "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 468d469a7..4211bb7ea 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr(object): + class contextmgr: def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return "{0}/auth".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/auth" @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,8 +41,32 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. + + Parameters + ---------- + auth_req : Credentials + The credentials object to use for signing in. Can be a TableauAuth, + PersonalAccessTokenAuth, or JWTAuth object. + + Returns + ------- + contextmgr + A context manager that will sign out of the server upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an auth object + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + + >>> # create an instance for your server + >>> server = TSC.Server('https://SERVER_URL') + + >>> # call the sign-in method with the auth object + >>> server.auth.sign_in(tableau_auth) """ - url = "{0}/{1}".format(self.baseurl, "signin") + url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -63,22 +87,25 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: + """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: - url = "{0}/{1}".format(self.baseurl, "signout") + """Sign out of current session.""" + url = f"{self.baseurl}/signout" # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -88,7 +115,34 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - url = "{0}/{1}".format(self.baseurl, "switchSite") + """ + Switch to a different site on the server. This will sign out of the + current site and sign in to the new site. If used as a context manager, + will sign out of the new site upon exit. + + Parameters + ---------- + site_item : SiteItem + The site to switch to. + + Returns + ------- + contextmgr + A context manager that will sign out of the new site upon exit. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # Find the site you want to switch to + >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") + >>> # switch to the new site + >>> with server.auth.switch_site(new_site): + >>> # do something on the new site + >>> pass + + """ + url = f"{self.baseurl}/switchSite" switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -104,11 +158,14 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) + logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") + """ + Revokes all personal access tokens for all server admins on the server. + """ + url = f"{self.baseurl}/revokeAllServerAdminTokens" self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 57a5b0100..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 List, Optional, Tuple, Union +from typing import Optional, Union +from collections.abc import Iterator -from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +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 @@ -33,11 +41,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super(CustomViews, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" @property def expurl(self) -> str: @@ -55,7 +63,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -68,8 +76,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying custom view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying custom view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,17 +91,53 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for custom view (ID: {view_item.id})") def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) 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]: @@ -105,10 +149,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = "{0}/{1}".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}" update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info("Updated custom view (ID: {0})".format(view_item.id)) + logger.info(f"Updated custom view (ID: {view_item.id})") return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -117,9 +161,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, view_id) + url = f"{self.baseurl}/{view_id}" self.delete_request(url) - logger.info("Deleted single custom view (ID: {0})".format(view_id)) + logger.info(f"Deleted single custom view (ID: {view_id})") @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -144,7 +188,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 256a6e766..579001156 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super(DataAccelerationReport, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index fd02d2e4a..ba3ecd74f 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(DataAlerts, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") + url = f"{self.baseurl}/{dataAlert_id}" server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}".format(self.baseurl, dataAlert_id) + url = f"{self.baseurl}/{dataAlert_id}" self.delete_request(url) - logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) + logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) + url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" self.delete_request(url) - logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) + logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}/users" update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) + logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) + url = f"{self.baseurl}/{dataAlert_item.id}" update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) + logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 2f8fece07..c0e106eb2 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Union, Iterable, Set +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -15,7 +16,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super(Databases, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -23,7 +24,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") def get(self, req_options=None): @@ -40,8 +41,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info("Querying single database (ID: {0})".format(database_id)) - url = "{0}/{1}".format(self.baseurl, database_id) + logger.info(f"Querying single database (ID: {database_id})") + url = f"{self.baseurl}/{database_id}" server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +51,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, database_id) + url = f"{self.baseurl}/{database_id}" self.delete_request(url) - logger.info("Deleted single database (ID: {0})".format(database_id)) + logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") def update(self, database_item): @@ -60,10 +61,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}" update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info("Updated database item (ID: {0})".format(database_item.id)) + logger.info(f"Updated database item (ID: {database_item.id})") updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -78,10 +79,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info("Populated tables for database (ID: {0}".format(database_item.id)) + logger.info(f"Populated tables for database (ID: {database_item.id}") def _get_tables_for_database(self, database_item): - url = "{0}/{1}/tables".format(self.baseurl, database_item.id) + url = f"{self.baseurl}/{database_item.id}/tables" server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -127,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7f3a47075..6bd809c28 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -22,7 +23,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -57,7 +58,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super(Datasources, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -65,11 +66,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,8 +84,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info("Querying single datasource (ID: {0})".format(datasource_id)) - url = "{0}/{1}".format(self.baseurl, datasource_id) + logger.info(f"Querying single datasource (ID: {datasource_id})") + url = f"{self.baseurl}/{datasource_id}" server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,10 +100,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections(self, datasource_item, req_options=None): - url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -113,9 +114,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}" self.delete_request(url) - logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) + logger.info(f"Deleted single datasource (ID: {datasource_id})") # Download 1 datasource by id @api(version="2.0") @@ -152,11 +153,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = "{0}/{1}".format(self.baseurl, datasource_item.id) + url = f"{self.baseurl}/{datasource_item.id}" update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) + logger.info(f"Updated datasource item (ID: {datasource_item.id})") updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -165,7 +166,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -174,18 +175,16 @@ def update_connection( return None if len(connections) > 1: - logger.debug("Multiple connections returned ({0})".format(len(connections))) + logger.debug(f"Multiple connections returned ({len(connections)})") connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info( - "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) - ) + logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -194,7 +193,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -203,7 +202,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -223,12 +222,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) + logger.debug(f"Publishing file `{filename}`, size `{file_size}`") # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -247,10 +246,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = "Unsupported file type {}".format(file_type) + error = f"Unsupported file type {file_type}" raise ValueError(error) - filename = "{}.{}".format(datasource_item.name, file_extension) + filename = f"{datasource_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -261,27 +260,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?datasourceType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -309,11 +308,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) + logger.info(f"Published {filename} (JOB_ID: {new_job.id}") return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) + logger.info(f"Published {filename} (ID: {new_datasource.id})") return new_datasource @api(version="3.13") @@ -327,23 +326,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = "{0}/{1}/data".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/data" elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) + url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" else: assert isinstance(datasource_or_connection_item, str) - url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) + url = f"{self.baseurl}/{datasource_or_connection_item}/data" if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) - logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) + logger.info(f"Uploading {payload} to server with chunking method for Update job") upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = "{0}?uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}?uploadSessionId={upload_session_id}" json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -356,7 +355,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -390,12 +389,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) + logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -413,9 +412,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, datasource_id) + url = f"{self.baseurl}/{datasource_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) + url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -437,9 +436,7 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) - ) + logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path @api(version="2.3") @@ -449,19 +446,17 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info( - "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) - ) + logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 19112d713..499324e8e 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,8 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Callable, Optional, Union +from collections.abc import Sequence if TYPE_CHECKING: from ..server import Server @@ -25,7 +26,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -33,23 +34,25 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl()) + return f"" __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) + logger.info(f"Updated default {content_type} permissions for resource {resource.id}") logger.info(permissions) return permissions - def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: + def delete_default_permission( + self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -65,29 +68,27 @@ def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, c ) ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> List[PermissionsRule]: + def permission_fetcher() -> list[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) + logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None - ) -> List[PermissionsRule]: - url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) + self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None + ) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 5296523ee..90e31483b 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super(_DataQualityWarningEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{0}/sites/{1}/dataQualityWarnings/{2}".format( + return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def update(self, resource, warning): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info("Added dqw for resource {0}".format(resource.id)) + logger.info(f"Added dqw for resource {resource.id}") return warnings def clear(self, resource): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) + url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_data_quality_warnings(self, item, req_options=None): - url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) + url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index be0602df5..9e1160705 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,12 +8,9 @@ from typing import ( Any, Callable, - Dict, Generic, - List, Optional, TYPE_CHECKING, - Tuple, TypeVar, Union, ) @@ -22,6 +19,7 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -56,7 +54,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -82,7 +80,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) + parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -90,12 +88,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) + logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: response = method(url, **parameters) - logger.debug("[{}] Call finished".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Call finished") except Exception as e: - logger.debug("Error making request to server: {}".format(e)) + logger.debug(f"Error making request to server: {e}") raise e return response @@ -111,13 +109,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[Dict[str, Any]] = None, + parameters: Optional[dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug("request method {}, url: {}".format(method.__name__, url)) + logger.debug(f"request method {method.__name__}, url: {url}") if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -129,21 +127,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) + logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug("[{}] Request failed".format(datetime.timestamp())) + logger.debug(f"[{datetime.timestamp()}] Request failed") raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug("Server response from {0}".format(url)) + logger.debug(f"Server response from {url}") # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -154,16 +152,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug("Response status: {}".format(server_response)) + logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): - raise EnvironmentError("Response is not a http response?") + raise OSError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise NotSignedInError(server_response.content, url) + raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: @@ -183,9 +181,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = "Content type `{}`".format(content_type) + loggable_response = f"Content type `{content_type}`" if content_type == "application/octet-stream": - loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) + loggable_response = f"A stream of type {content_type} [Truncated File Contents]" elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -313,7 +311,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) + error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" warnings.warn(error) return func(self, *args, **kwargs) @@ -353,5 +351,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9dfd38da6..77332da3e 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,24 +1,31 @@ from defusedxml.ElementTree import fromstring -from typing import Optional +from typing import Mapping, Optional, TypeVar + + +def split_pascal_case(s: str) -> str: + return "".join([f" {c}" if c.isupper() else c for c in s]).strip() class TableauError(Exception): pass -class ServerResponseError(TableauError): - def __init__(self, code, summary, detail, url=None): +T = TypeVar("T") + + +class XMLError(TableauError): + def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: self.code = code self.summary = summary self.detail = detail self.url = url - super(ServerResponseError, self).__init__(str(self)) + super().__init__(str(self)) def __str__(self): - return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) + return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" @classmethod - def from_response(cls, resp, ns, url=None): + def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -33,6 +40,10 @@ def from_response(cls, resp, ns, url=None): return error_response +class ServerResponseError(XMLError): + pass + + class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -40,7 +51,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) + return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" class MissingRequiredFieldError(TableauError): @@ -51,6 +62,11 @@ class NotSignedInError(TableauError): pass +class FailedSignInError(XMLError, NotSignedInError): + def __str__(self): + return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" + + class ItemTypeNotAllowed(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 5f298f37e..8330e6d2c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info("Querying all favorites for user {0}".format(user_item.name)) - url = "{0}/{1}".format(self.baseurl, user_item.id) + logger.info(f"Querying all favorites for user {user_item.name}") + url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) + logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) + logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) + logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) + logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) + logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) + logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) + logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) - logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" + logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) - logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" + logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) - logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" + logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) - logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" + logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) - logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" + logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) - logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" + logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) - logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) + url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" + logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 0d30797c1..1ae10e72d 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super(Fileuploads, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self): - return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info("Initiated file upload session (ID: {0})".format(upload_id)) + logger.info(f"Initiated file upload session (ID: {upload_id})") return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = "{0}/{1}".format(self.baseurl, upload_id) + url = f"{self.baseurl}/{upload_id}" server_response = self.put_request(url, data, content_type) - logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) + logger.info(f"Uploading a chunk to session (ID: {upload_id})") return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,12 +52,10 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug("{} processing chunk...".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} processing chunk...") request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug("{} created chunk request".format(datetime.timestamp())) + logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info( - "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) - ) - logger.info("File upload finished (ID: {0})".format(upload_id)) + logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"File upload finished (ID: {upload_id})") return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index c339a0645..2c3bb84bc 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem, PaginationItem +from tableauserverclient.models import FlowRunItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,22 +16,24 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super(FlowRuns, self).__init__(parent_srv) + super().__init__(parent_srv) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" # Get all flows @api(version="3.10") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: + # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns + # does not return a PaginationItem. Suppressing the mypy error because the + # changes to the QuerySet class should permit this to function regardless. + def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items, pagination_item + return all_flow_run_items # Get 1 flow by id @api(version="3.10") @@ -39,21 +41,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_run_id)) - url = "{0}/{1}".format(self.baseurl, flow_run_id) + logger.info(f"Querying single flow (ID: {flow_run_id})") + url = f"{self.baseurl}/{flow_run_id}" server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: str) -> None: + def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = "{0}/{1}".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}" self.put_request(url) - logger.info("Deleted single flow (ID: {0})".format(id_)) + logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -69,7 +71,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) + logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index eea3f9710..9e21661e6 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 53d072f50..7eb5dc3ba 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,8 @@ import os from contextlib import closing from pathlib import Path -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.helpers.headers import fix_filename @@ -53,18 +54,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super(Flows, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -78,8 +79,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info("Querying single flow (ID: {0})".format(flow_id)) - url = "{0}/{1}".format(self.baseurl, flow_id) + logger.info(f"Querying single flow (ID: {flow_id})") + url = f"{self.baseurl}/{flow_id}" server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -94,10 +95,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) + logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -108,9 +109,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}" self.delete_request(url) - logger.info("Deleted single flow (ID: {0})".format(flow_id)) + logger.info(f"Deleted single flow (ID: {flow_id})") # Download 1 flow by id @api(version="3.3") @@ -118,7 +119,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = "{0}/{1}/content".format(self.baseurl, flow_id) + url = f"{self.baseurl}/{flow_id}/content" with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -137,7 +138,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) + logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path # Update flow @@ -150,28 +151,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = "{0}/{1}".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}" update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info("Updated flow item (ID: {0})".format(flow_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id})") updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) + url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) + logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = "{0}/{1}/run".format(self.baseurl, flow_item.id) + url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -180,7 +181,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -189,7 +190,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -213,30 +214,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = "{}.{}".format(flow_item.name, file_extension) + filename = f"{flow_item.name}.{file_extension}" file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = "{0}?flowType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?flowType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) + logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -259,7 +260,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) + logger.info(f"Published {filename} (ID: {new_flow.id})") return new_flow @api(version="3.3") @@ -294,7 +295,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 8acf31692..c512b011b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -19,10 +20,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -50,12 +51,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> Tuple[List[UserItem], PaginationItem]: - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + ) -> tuple[list[UserItem], PaginationItem]: + url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Populated users for group (ID: {0})".format(group_item.id)) + logger.info(f"Populated users for group (ID: {group_item.id})") return user_item, pagination_item @api(version="2.0") @@ -64,13 +65,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, group_id) + url = f"{self.baseurl}/{group_id}" self.delete_request(url) - logger.info("Deleted single group (ID: {0})".format(group_id)) + logger.info(f"Deleted single group (ID: {group_id})") @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = "{0}/{1}".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}" if not group_item.id: error = "Group item missing ID." @@ -83,7 +84,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info("Updated group item (ID: {0})".format(group_item.id)) + logger.info(f"Updated group item (ID: {group_item.id})") if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -118,9 +119,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) + url = f"{self.baseurl}/{group_item.id}/users/{user_id}" self.delete_request(url) - logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -132,7 +133,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info("Removed users to group (ID: {0})".format(group_item.id)) + logger.info(f"Removed users to group (ID: {group_item.id})") return None @api(version="2.0") @@ -144,15 +145,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}/users".format(self.baseurl, group_item.id) + url = f"{self.baseurl}/{group_item.id}/users" add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) + logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -162,7 +163,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Added users to group (ID: {0})".format(group_item.id)) + logger.info(f"Added users to group (ID: {group_item.id})") return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 06e7cc627..c7f5ed0e5 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> Tuple[List[GroupSetItem], PaginationItem]: + ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index ae8cf2633..723d3dd38 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, Tuple, Union +from typing import Optional, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = "{0}/{1}".format(self.baseurl, job_id) + url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) + logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index 374130509..ede4d38e3 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import Optional, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 38c3eebb6..e5dbcbcf8 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/graphql" @property def control_baseurl(self): - return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) + return f"{self.parent_srv.server_address}/api/metadata/v1/control" @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index ab1ec5852..3fea1f5b6 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super(Metrics, self).__init__(parent_srv) + super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info("Querying single metric (ID: {0})".format(metric_id)) - url = "{0}/{1}".format(self.baseurl, metric_id) + logger.info(f"Querying single metric (ID: {metric_id})") + url = f"{self.baseurl}/{metric_id}" server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, metric_id) + url = f"{self.baseurl}/{metric_id}" self.delete_request(url) - logger.info("Deleted single metric (ID: {0})".format(metric_id)) + logger.info(f"Deleted single metric (ID: {metric_id})") # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = "{0}/{1}".format(self.baseurl, metric_item.id) + url = f"{self.baseurl}/{metric_item.id}" update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info("Updated metric item (ID: {0})".format(metric_item.id)) + logger.info(f"Updated metric item (ID: {metric_item.id})") return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 4433625f2..10d420ff7 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, List, Optional, Union +from typing import Callable, TYPE_CHECKING, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super(_PermissionsEndpoint, self).__init__(parent_srv) + super().__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return "".format(self.owner_baseurl) + return f"" - def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: - url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) + def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: + url = f"{self.owner_baseurl()}/{resource.id}/permissions" update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) + logger.info(f"Updated permissions for resource {resource.id}: {permissions}") return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( + url = "{}/{}/permissions/{}/{}/{}/{}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,13 +63,11 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[Permi mode, ) - logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) + logger.debug(f"Removing {mode} permission for capability {capability}") self.delete_request(url) - logger.info( - "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) - ) + logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") def populate(self, item: TableauItem): if not item.id: @@ -80,12 +78,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info("Populated permissions for item (ID: {0})".format(item.id)) + logger.info(f"Populated permissions for item (ID: {item.id})") def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) + url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) + logger.info(f"Permissions for resource {item.id}: {permissions}") return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 565817e37..74bb865c7 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,9 +5,10 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -20,17 +21,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super(Projects, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -43,9 +44,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, project_id) + url = f"{self.baseurl}/{project_id}" self.delete_request(url) - logger.info("Deleted single project (ID: {0})".format(project_id)) + logger.info(f"Deleted single project (ID: {project_id})") @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -54,10 +55,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = "{0}/{1}".format(self.baseurl, project_item.id) + url = f"{self.baseurl}/{project_item.id}" update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info("Updated project item (ID: {0})".format(project_item.id)) + logger.info(f"Updated project item (ID: {project_item.id})") updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -66,11 +67,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) + url = f"{self.baseurl}?publishSamples={project_item._samples}" create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new project (ID: {0})".format(new_project.id)) + logger.info(f"Created new project (ID: {new_project.id})") return new_project @api(version="2.0") @@ -78,85 +79,135 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item, rules): + def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item, rules): + def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item): + def populate_workbook_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item): + def populate_datasource_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item): + def populate_metric_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item): + def populate_datarole_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item): + def populate_flow_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item): + def populate_lens_default_permissions(self, item: ProjectItem) -> None: self._default_permissions.populate_default_permissions(item, Resource.Lens) + @api(version="3.23") + def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) + + @api(version="3.23") + def populate_database_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Database) + + @api(version="3.23") + def populate_table_default_permissions(self, item: ProjectItem) -> None: + self._default_permissions.populate_default_permissions(item, Resource.Table) + @api(version="2.1") - def update_workbook_default_permissions(self, item, rules): + def update_workbook_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions(self, item, rules): + def update_datasource_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions(self, item, rules): + def update_metric_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions(self, item, rules): + def update_datarole_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item, rules): + def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item, rules): + def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) + @api(version="3.23") + def update_virtualconnection_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) + + @api(version="3.23") + def update_database_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Database) + + @api(version="3.23") + def update_table_default_permissions( + self, item: ProjectItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) + @api(version="2.1") - def delete_workbook_default_permissions(self, item, rule): + def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item, rule): + def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item, rule): + def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item, rule): + def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item, rule): + def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item, rule): + def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + @api(version="3.23") + def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) + + @api(version="3.23") + def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Database) + + @api(version="3.23") + def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 1894e3b8a..63c03b3e3 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,7 @@ import abc import copy -from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from collections.abc import Iterable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -24,7 +25,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = "{0}/{1}/tags".format(baseurl, resource_id) + url = f"{baseurl}/{resource_id}/tags" add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -39,7 +40,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) + url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: self.delete_request(url) @@ -59,7 +60,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info("Updated tags to {0}".format(resource_item.tags)) + logger.info(f"Updated tags to {resource_item.tags}") class Response(Protocol): @@ -68,8 +69,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: Set[str] - _initial_tags: Set[str] + tags: set[str] + _initial_tags: set[str] @property def id(self) -> Optional[str]: @@ -95,14 +96,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -118,7 +119,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -158,9 +159,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) @@ -170,9 +171,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: if isinstance(tags, str): - tag_set = set([tags]) + tag_set = {tags} else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index cfaee3324..eec4536f9 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return "{0}/schedules".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/schedules" @property def siteurl(self) -> str: - return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info("Querying a single schedule by id ({})".format(schedule_id)) - url = "{0}/{1}".format(self.baseurl, schedule_id) + logger.info(f"Querying a single schedule by id ({schedule_id})") + url = f"{self.baseurl}/{schedule_id}" server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, schedule_id) + url = f"{self.baseurl}/{schedule_id}" self.delete_request(url) - logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + logger.info(f"Deleted single schedule (ID: {schedule_id})") @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, schedule_item.id) + url = f"{self.baseurl}/{schedule_item.id}" update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + logger.info(f"Updated schedule item (ID: {schedule_item.id})") updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + logger.info(f"Created new schedule (ID: {new_schedule.id})") return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> List[AddResponse]: + ) -> list[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: List[ - Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: list[ + tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -115,8 +115,7 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - # list() is needed for python 3.x compatibility - return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] + return [x for x in results if not x.result] def _add_to( self, @@ -133,13 +132,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) + url = f"{self.siteurl}/{schedule_id}/{type_}s" add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) + logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index 26aaf2910..dc934496a 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -21,15 +22,49 @@ def serverInfo(self): return self._info def __repr__(self): - return "".format(self.serverInfo) + return f"" @property - def baseurl(self): - return "{0}/serverInfo".format(self.parent_srv.baseurl) + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self): - """Retrieve the server info for the server. This is an unauthenticated call""" + def get(self) -> Union[ServerInfoItem, None]: + """ + Retrieve the build and version information for the server. + + This method makes an unauthenticated call, so no sign in or + authentication token is required. + + Returns + ------- + :class:`~tableauserverclient.models.ServerInfoItem` + + Raises + ------ + :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` + Raised when the server info endpoint is not found. + + :class:`~tableauserverclient.exceptions.EndpointUnavailableError` + Raised when the server info endpoint is not available. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # set the version number > 2.3 + >>> # the server_info.get() method works in 2.4 and later + >>> server.version = '2.5' + + >>> s_info = server.server_info.get() + >>> print("\nServer info:") + >>> print("\tProduct version: {0}".format(s_info.product_version)) + >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) + >>> print("\tBuild number: {0}".format(s_info.build_number)) + """ try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index dfec49ae1..55d2a5ad0 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,20 +8,49 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..request_options import RequestOptions class Sites(Endpoint): + """ + Using the site methods of the Tableau Server REST API you can: + + List sites on a server or get details of a specific site + Create, update, or delete a site + List views in a site + Encrypt, decrypt, or reencrypt extracts on a site + + """ + @property def baseurl(self) -> str: - return "{0}/sites".format(self.parent_srv.baseurl) + return f"{self.parent_srv.baseurl}/sites" # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + """ + Query all sites on the server. This method requires server admin + permissions. This endpoint is paginated, meaning that the server will + only return a subset of the data at a time. The response will contain + information about the total number of sites and the number of sites + returned in the current response. Use the PaginationItem object to + request more data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request. + + Returns + ------- + tuple[list[SiteItem], PaginationItem] + """ logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -33,6 +62,33 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_id : str + The site ID. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -40,20 +96,45 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info("Querying single site (ID: {0})".format(site_id)) - url = "{0}/{1}".format(self.baseurl, site_id) + logger.info(f"Querying single site (ID: {site_id})") + url = f"{self.baseurl}/{site_id}" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + site_name : str + The site name. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if not site_name: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info("Querying single site (Name: {0})".format(site_name)) - url = "{0}/{1}?key=name".format(self.baseurl, site_name) + logger.info(f"Querying single site (Name: {site_name})") + url = f"{self.baseurl}/{site_name}?key=name" print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -61,6 +142,31 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: + """ + Query a single site on the server. You can only retrieve the site that + you are currently authenticated for. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site + + Parameters + ---------- + content_url : str + The content URL. + + Returns + ------- + SiteItem + + Raises + ------ + ValueError + If the site name is not defined. + + Examples + -------- + >>> site = server.sites.get_by_name('Tableau') + + """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -68,15 +174,51 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info("Querying single site (Content URL: {0})".format(content_url)) + logger.info(f"Querying single site (Content URL: {content_url})") logger.debug("Querying other sites requires Server Admin permissions") - url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) + url = f"{self.baseurl}/{content_url}?key=contentUrl" server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: + """ + Modifies the settings for site. + + The site item object must include the site ID and overrides all other settings. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site + + Parameters + ---------- + site_item : SiteItem + The site item that you want to update. The settings specified in the + site item override the current site settings. + + Returns + ------- + SiteItem + The site item object that was updated. + + Raises + ------ + MissingRequiredFieldError + If the site item is missing an ID. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> ... + >>> site_item.name = 'New Name' + >>> updated_site = server.sites.update(site_item) + + """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -90,30 +232,94 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_item.id) + url = f"{self.baseurl}/{site_item.id}" update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info("Updated site item (ID: {0})".format(site_item.id)) + logger.info(f"Updated site item (ID: {site_item.id})") update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: + """ + Deletes the specified site from the server. You can only delete the site + if you are a Server Admin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + ValueError + If the site ID does not match the site for which you are currently authenticated. + + Examples + -------- + >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}" if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) + logger.info(f"Deleted single site (ID: {site_id}) and signed out") # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: + """ + Creates a new site on the server for the specified site item object. + + Tableau Server only. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site + + Parameters + ---------- + site_item : SiteItem + The settings for the site that you want to create. You need to + create an instance of SiteItem and pass it to the create method. + + Returns + ------- + SiteItem + The site item object that was created. + + Raises + ------ + ValueError + If the site admin mode is set to ContentOnly and a user quota is also set. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create an instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # create shortcut for admin mode + >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers + + >>> # create a new SiteItem + >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) + + >>> # call the sites create method with the SiteItem + >>> new_site = server.sites.create(new_site) + + + """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -123,33 +329,92 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Created new site (ID: {0})".format(new_site.id)) + logger.info(f"Created new site (ID: {new_site.id})") return new_site @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: + """ + Encrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/encrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: + """ + Decrypts all extracts on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/decrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: + """ + Reencrypt all extracts on a site with new encryption keys. If no site is + specified, extracts on the default site will be reencrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts + + Parameters + ---------- + site_id : str + The site ID. + + Raises + ------ + ValueError + If the site ID is not defined. + + Examples + -------- + >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + + """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) + url = f"{self.baseurl}/{site_id}/reencrypt-extracts" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index a9f2e7bf5..c9abc9b06 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info("Querying a single subscription by id ({})".format(subscription_id)) - url = "{}/{}".format(self.baseurl, subscription_id) + logger.info(f"Querying a single subscription by id ({subscription_id})") + url = f"{self.baseurl}/{subscription_id}" server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info("Creating a subscription ({})".format(subscription_item)) + logger.info(f"Creating a subscription ({subscription_item})") url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, subscription_id) + url = f"{self.baseurl}/{subscription_id}" self.delete_request(url) - logger.info("Deleted subscription (ID: {0})".format(subscription_id)) + logger.info(f"Deleted subscription (ID: {subscription_id})") @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, subscription_item.id) + url = f"{self.baseurl}/{subscription_item.id}" update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) + logger.info(f"Updated subscription item (ID: {subscription_item.id})") return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 36ef78c0a..120d3ba9c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing import Iterable, Set, Union +from typing import Union +from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -15,14 +16,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super(Tables, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") def get(self, req_options=None): @@ -39,8 +40,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info("Querying single table (ID: {0})".format(table_id)) - url = "{0}/{1}".format(self.baseurl, table_id) + logger.info(f"Querying single table (ID: {table_id})") + url = f"{self.baseurl}/{table_id}" server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -49,9 +50,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, table_id) + url = f"{self.baseurl}/{table_id}" self.delete_request(url) - logger.info("Deleted single table (ID: {0})".format(table_id)) + logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") def update(self, table_item): @@ -59,10 +60,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}" update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info("Updated table item (ID: {0})".format(table_item.id)) + logger.info(f"Updated table item (ID: {table_item.id})") updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -80,10 +81,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info("Populated columns for table (ID: {0}".format(table_item.id)) + logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table(self, table_item, req_options=None): - url = "{0}/{1}/columns".format(self.baseurl, table_item.id) + url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,12 +92,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) + url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) + logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") return column @api(version="3.5") @@ -128,7 +129,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index a727a515f..eb82c43bc 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return "{}es".format(task_type) + return f"{task_type}es" else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> Tuple[List[TaskItem], PaginationItem]: + ) -> tuple[list[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) return server_response.content @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/{2}/runNow".format( + url = "{}/{}/{}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) + url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index c4b6418b7..d81907ae9 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import List, Optional, Tuple +from typing import Optional from tableauserverclient.server.query import QuerySet @@ -14,13 +14,75 @@ class Users(QuerysetEndpoint[UserItem]): + """ + The user resources for Tableau Server are defined in the UserItem class. + The class corresponds to the user resources you can access using the + Tableau Server REST API. The user methods are based upon the endpoints for + users in the REST API and operate on the UserItem class. Only server and + site administrators can access the user resources. + """ + @property def baseurl(self) -> str: - return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + """ + Query all users on the site. Request is paginated and returns a subset of users. + By default, the request returns the first 100 users on the site. + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + tuple[list[UserItem], PaginationItem] + Returns a tuple with a list of UserItem objects and a PaginationItem object. + + Raises + ------ + ServerResponseError + code: 400006 + summary: Invalid page number + detail: The page number is not an integer, is less than one, or is + greater than the final page number for users at the requested + page size. + + ServerResponseError + code: 400007 + summary: Invalid page size + detail: The page size parameter is not an integer, is less than one. + + ServerResponseError + code: 403014 + summary: Page size limit exceeded + detail: The specified page size is larger than the maximum page size + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + >>> users_page, pagination_item = server.users.get() + >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) + >>> print([user.name for user in users_page]) + """ logger.info("Querying all users on site") if req_options is None: @@ -36,55 +98,253 @@ def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: + """ + Query a single user by ID. + + Parameters + ---------- + user_id : str + The ID of the user to query. + + Returns + ------- + UserItem + The user item that was queried. + + Raises + ------ + ValueError + If the user ID is not specified. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 403133 + summary: Query user permissions forbidden + detail: The user does not have permissions to query user information + for other users + + ServerResponseError + code: 404002 + summary: User not found + detail: The user ID in the URI doesn't correspond to an existing user. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not GET. + + Examples + -------- + >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info("Querying single user (ID: {0})".format(user_id)) - url = "{0}/{1}".format(self.baseurl, user_id) + logger.info(f"Querying single user (ID: {user_id})") + url = f"{self.baseurl}/{user_id}" server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + """ + Modifies information about the specified user. + + If Tableau Server is configured to use local authentication, you can + update the user's name, email address, password, or site role. + + If Tableau Server is configured to use Active Directory + authentication, you can change the user's display name (full name), + email address, and site role. However, if you synchronize the user with + Active Directory, the display name and email address will be + overwritten with the information that's in Active Directory. + + For Tableau Cloud, you can update the site role for a user, but you + cannot update or change a user's password, user name (email address), + or full name. + + Parameters + ---------- + user_item : UserItem + The user item to update. + + password : Optional[str] + The new password for the user. + + Returns + ------- + UserItem + The user item that was updated. + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> user.fullname = 'New Full Name' + >>> updated_user = server.users.update(user) + + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}".format(self.baseurl, user_item.id) + url = f"{self.baseurl}/{user_item.id}" update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info("Updated user item (ID: {0})".format(user_item.id)) + logger.info(f"Updated user item (ID: {user_item.id})") updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + """ + Removes a user from the site. You can also specify a user to map the + assets to when you remove the user. + + Parameters + ---------- + user_id : str + The ID of the user to remove. + + map_assets_to : Optional[str] + The ID of the user to map the assets to when you remove the user. + + Returns + ------- + None + + Raises + ------ + ValueError + If the user ID is not specified. + + Examples + -------- + >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + """ if not user_id: error = "User ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, user_id) + url = f"{self.baseurl}/{user_id}" if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info("Removed single user (ID: {0})".format(user_id)) + logger.info(f"Removed single user (ID: {user_id})") # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: + """ + Adds the user to the site. + + To add a new user to the site you need to first create a new user_item + (from UserItem class). When you create a new user, you specify the name + of the user and their site role. For Tableau Cloud, you also specify + the auth_setting attribute in your request. When you add user to + Tableau Cloud, the name of the user must be the email address that is + used to sign in to Tableau Cloud. After you add a user, Tableau Cloud + sends the user an email invitation. The user can click the link in the + invitation to sign in and update their full name and password. + + Parameters + ---------- + user_item : UserItem + The user item to add to the site. + + Returns + ------- + UserItem + The user item that was added to the site with attributes from the + site populated. + + Raises + ------ + ValueError + If the user item is missing a name + + ValueError + If the user item is missing a site role + + ServerResponseError + code: 400000 + summary: Bad Request + detail: The content of the request body is missing or incomplete, or + contains malformed XML. + + ServerResponseError + code: 400003 + summary: Bad Request + detail: The user authentication setting ServerDefault is not + supported for you site. Try again using TableauIDWithMFA instead. + + ServerResponseError + code: 400013 + summary: Invalid site role + detail: The value of the siteRole attribute must be Explorer, + ExplorerCanPublish, SiteAdministratorCreator, + SiteAdministratorExplorer, Unlicensed, or Viewer. + + ServerResponseError + code: 404000 + summary: Site not found + detail: The site ID in the URI doesn't correspond to an existing site. + + ServerResponseError + code: 404002 + summary: User not found + detail: The server is configured to use Active Directory for + authentication, and the username specified in the request body + doesn't match an existing user in Active Directory. + + ServerResponseError + code: 405000 + summary: Invalid request method + detail: Request type was not POST. + + ServerResponseError + code: 409000 + summary: User conflict + detail: The specified user already exists on the site. + + ServerResponseError + code: 409005 + summary: Guest user conflict + detail: The Tableau Server API doesn't allow adding a user with the + guest role to a site. + + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> new_user = server.users.add(new_user) + + """ url = self.baseurl - logger.info("Add user {}".format(user_item.name)) + logger.info(f"Add user {user_item.name}") add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info("Added new user (ID: {0})".format(new_user.id)) + logger.info(f"Added new user (ID: {new_user.id})") return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: List[UserItem]): + def add_all(self, users: list[UserItem]): created = [] failed = [] for user in users: @@ -98,7 +358,7 @@ def add_all(self, users: List[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -122,6 +382,42 @@ def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Returns information about the workbooks that the specified user owns + and has Read (view) permissions for. + + This method retrieves the workbook information for the specified user. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the users, the workbook information + for each user is not included. Use this method to retrieve information + about the workbooks that the user owns or has Read (view) permissions. + The method adds the list of workbooks to the user item object + (user_item.workbooks). + + Parameters + ---------- + user_item : UserItem + The user item to populate workbooks for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_workbooks(user) + >>> for wb in user.workbooks: + >>> print(wb.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -133,20 +429,71 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[WorkbookItem], PaginationItem]: - url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) + ) -> tuple[list[WorkbookItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/workbooks" server_response = self.get_request(url, req_options) - logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: + """ + Populate the favorites for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate favorites for. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') + >>> server.users.populate_favorites(user) + >>> for obj_type, items in user.favorites.items(): + >>> print(f"Favorites for {obj_type}:") + >>> for item in items: + >>> print(item.name) + """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the groups for the user. + + Parameters + ---------- + user_item : UserItem + The user item to populate groups for. + + req_options : Optional[RequestOptions] + Optional request options to filter and sort the results. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the user item is missing an ID. + + Examples + -------- + >>> server.users.populate_groups(user) + >>> for group in user.groups: + >>> print(group.name) + """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -161,10 +508,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[GroupItem], PaginationItem]: - url = "{0}/{1}/groups".format(self.baseurl, user_item.id) + ) -> tuple[list[GroupItem], PaginationItem]: + url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) - logger.info("Populated groups for user (ID: {0})".format(user_item.id)) + logger.info(f"Populated groups for user (ID: {user_item.id})") group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f2ccf658e..3709fc41d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,7 +11,8 @@ from tableauserverclient.helpers.logging import logger -from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable, Iterator if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -25,22 +26,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super(Views, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" @property def baseurl(self) -> str: - return "{0}/views".format(self.siteurl) + return f"{self.siteurl}/views" @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> Tuple[List[ViewItem], PaginationItem]: + ) -> tuple[list[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -55,8 +56,8 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info("Querying single view (ID: {0})".format(view_id)) - url = "{0}/{1}".format(self.baseurl, view_id) + logger.info(f"Querying single view (ID: {view_id})") + url = f"{self.baseurl}/{view_id}" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -72,10 +73,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated preview image for view (ID: {view_item.id})") def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) + url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" server_response = self.get_request(url) image = server_response.content return image @@ -90,10 +91,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info("Populated image for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated image for view (ID: {view_item.id})") def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = "{0}/{1}/image".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @@ -108,10 +109,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated pdf for view (ID: {view_item.id})") def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -126,10 +127,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info("Populated csv for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated csv for view (ID: {view_item.id})") def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/data".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{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) @@ -144,10 +145,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info("Populated excel for view (ID: {0})".format(view_item.id)) + logger.info(f"Populated excel for view (ID: {view_item.id})") def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) + url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -176,7 +177,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index f71db00cc..944b72502 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,8 @@ from functools import partial import json from pathlib import Path -from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union +from typing import Optional, TYPE_CHECKING, Union +from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -28,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +45,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[ConnectionItem], PaginationItem]: + ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -83,7 +84,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> Tuple[List[RevisionItem], PaginationItem]: + ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -159,7 +160,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> Set[str]: + ) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 597f9c425..06643f99d 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple +from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super(Webhooks, self).__init__(parent_srv) + super().__init__(parent_srv) @property def baseurl(self) -> str: - return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: 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) + logger.info(f"Querying single webhook (ID: {webhook_id})") + url = f"{self.baseurl}/{webhook_id}" server_response = self.get_request(url) return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}" self.delete_request(url) - logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) + logger.info(f"Deleted single webhook (ID: {webhook_id})") @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: 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)) + logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = "{0}/{1}/test".format(self.baseurl, webhook_id) + url = f"{self.baseurl}/{webhook_id}/test" testOutcome = self.get_request(url) - logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) + logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index da6eda3de..460017d1a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,6 +7,7 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -25,15 +26,11 @@ from tableauserverclient.server import RequestFactory from typing import ( - Iterable, - List, Optional, - Sequence, - Set, - Tuple, TYPE_CHECKING, Union, ) +from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -61,18 +58,34 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super(Workbooks, self).__init__(parent_srv) + super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + """ + Queries the server and returns information about the workbooks the site. + + Parameters + ---------- + req_options : RequestOptions, optional + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific workbook, you could specify the name + of the workbook or the name of the owner. + + Returns + ------- + Tuple containing one page's worth of workbook items and pagination + information. + """ logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -83,18 +96,44 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: + """ + Returns information about the specified workbook on the site. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + WorkbookItem + The workbook item. + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info("Querying single workbook (ID: {0})".format(workbook_id)) - url = "{0}/{1}".format(self.baseurl, workbook_id) + logger.info(f"Querying single workbook (ID: {workbook_id})") + url = f"{self.baseurl}/{workbook_id}" server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/refresh".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/refresh" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -107,10 +146,37 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[List["DatasourceItem"]] = None, + datasources: Optional[list["DatasourceItem"]] = None, ) -> JobItem: + """ + Create one or more extracts on 1 workbook, optionally encrypted. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to create extracts for. + + encrypt : bool, default False + Set to True to encrypt the extracts. + + includeAll : bool, default True + If True, all data sources in the workbook will have an extract + created for them. If False, then a data source must be supplied in + the request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to create + extracts for. Only required if includeAll is False. + + Returns + ------- + JobItem + The job item for the extract creation. + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) + url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -120,8 +186,31 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: + """ + Delete all extracts of embedded datasources on 1 workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to delete extracts from. + + includeAll : bool, default True + If True, all data sources in the workbook will have their extracts + deleted. If False, then a data source must be supplied in the + request. + + datasources : list[DatasourceItem] | None + List of DatasourceItem objects for the data sources to delete + extracts from. Only required if includeAll is False. + + Returns + ------- + JobItem + """ id_ = getattr(workbook_item, "id", workbook_item) - url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) + url = f"{self.baseurl}/{id_}/deleteExtract" datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -130,12 +219,24 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: + """ + Deletes a workbook with the specified ID. + + Parameters + ---------- + workbook_id : str + The workbook ID. + + Returns + ------- + None + """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}" self.delete_request(url) - logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) + logger.info(f"Deleted single workbook (ID: {workbook_id})") # Update workbook @api(version="2.0") @@ -145,6 +246,29 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: + """ + Modifies an existing workbook. Use this method to change the owner or + the project that the workbook belongs to, or to change whether the + workbook shows views in tabs. The workbook item must include the + workbook ID and overrides the existing settings. + + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook + for a list of fields that can be updated. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. ID is required. Other fields are + optional. Any fields that are not specified will not be changed. + + include_view_acceleration_status : bool, default False + Set to True to include the view acceleration status in the response. + + Returns + ------- + WorkbookItem + The updated workbook item. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -152,27 +276,47 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = "{0}/{1}".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}" if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) + logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) + """ + Updates a workbook connection information (server addres, server port, + user name, and password). + + The workbook connections must be populated before the strings can be + updated. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ + url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info( - "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) - ) + logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection # Download workbook contents with option of passing in filepath @@ -185,6 +329,34 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook to the specified directory (optional). + + Parameters + ---------- + workbook_id : str + The workbook ID. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + return self.download_revision( workbook_id, None, @@ -195,18 +367,48 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: + """ + Populates (or gets) a list of views for a workbook. + + You must first call this method to populate views before you can iterate + through the views. + + This method retrieves the view information for the specified workbook. + The REST API is designed to return only the information you ask for + explicitly. When you query for all the workbooks, the view information + is not included. Use this method to retrieve the views. The method adds + the list of views to the workbook item (workbook_item.views). This is a + list of ViewItem. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate views for. + + usage : bool, default False + Set to True to include usage statistics for each view. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> List[ViewItem]: + def view_fetcher() -> list[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated views for workbook (ID: {workbook_item.id})") - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: - url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: + url = f"{self.baseurl}/{workbook_item.id}/views" if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -220,6 +422,36 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> L # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: + """ + Populates a list of data source connections for the specified workbook. + + You must populate connections before you can iterate through the + connections. + + This method retrieves the data source connection information for the + specified workbook. The REST API is designed to return only the + information you ask for explicitly. When you query all the workbooks, + the data source connection information is not included. Use this method + to retrieve the connection information for any data sources used by the + workbook. The method adds the list of data connections to the workbook + item (workbook_item.connections). This is a list of ConnectionItem. + + REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -228,12 +460,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[ConnectionItem]: - url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) + ) -> list[ConnectionItem]: + url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -241,6 +473,34 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PDF for the specified workbook item. + + This method populates a PDF with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the page type + and orientation of the PDF content, as well as the maximum age of + the PDF rendered on the server. See PDFRequestOptions class for more + details. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -249,16 +509,46 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + """ + Populates the PowerPoint for the specified workbook item. + + This method populates a PowerPoint with image(s) of the workbook view(s) you + specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the PDF for. + + req_options : RequestOptions, optional + (Optional) You can pass in request options to specify the maximum + number of minutes a workbook .pptx will be cached before being + refreshed. To prevent multiple .pptx requests from overloading the + server, the shortest interval you can set is one minute. There is no + maximum value, but the server job enacting the caching action may + expire before a long cache period is reached. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -267,10 +557,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -278,6 +568,26 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: + """ + This method gets the preview image (thumbnail) for the specified workbook item. + + This method uses the workbook's ID to get the preview image. The method + adds the preview image to the workbook item (workbook_item.preview_image). + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate the preview image for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -286,24 +596,75 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) + url = f"{self.baseurl}/{workbook_item.id}/previewImage" server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: + """ + Populates the permissions for the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions + + Parameters + ---------- + item : WorkbookItem + The workbook item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified workbook item. The method + replaces the existing permissions with the new permissions. Any missing + permissions are removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + resource : WorkbookItem + The workbook item to update permissions for. + + rules : list[PermissionsRule] + A list of permissions rules to apply to the workbook item. + + Returns + ------- + list[PermissionsRule] + The updated permissions rules. + """ return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: + """ + Deletes a single permission rule from the specified workbook item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission + + Parameters + ---------- + item : WorkbookItem + The workbook item to delete the permission from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -319,10 +680,87 @@ def publish( skip_connection_check: bool = False, parameters=None, ): + """ + Publish a workbook to the specified site. + + Note: The REST API cannot automatically include extracts or other + resources that the workbook uses. Therefore, a .twb file that uses data + from an Excel or csv file on a local computer cannot be published, + unless you package the data and workbook in a .twbx file, or publish the + data source separately. + + For workbooks that are larger than 64 MB, the publish method + automatically takes care of chunking the file in parts for uploading. + Using this method is considerably more convenient than calling the + publish REST APIs directly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook_item specifies the workbook you are publishing. When + you are adding a workbook, you need to first create a new instance + of a workbook_item that includes a project_id of an existing + project. The name of the workbook will be the name of the file, + unless you also specify a name for the new workbook when you create + the instance. + + file : Path or File object + The file path or file object of the workbook to publish. When + providing a file object, you must also specifiy the name of the + workbook in your instance of the workbook_itemworkbook_item , as + the name cannot be derived from the file name. + + mode : str + Specifies whether you are publishing a new workbook (CreateNew) or + overwriting an existing workbook (Overwrite). You cannot appending + workbooks. You can also use the publish mode attributes, for + example: TSC.Server.PublishMode.Overwrite. + + connections : list[ConnectionItem] | None + List of ConnectionItems objects for the connections created within + the workbook. + + as_job : bool, default False + Set to True to run the upload as a job (asynchronous upload). If set + to True a job will start to perform the publishing process and a Job + object is returned. Defaults to False. + + skip_connection_check : bool, default False + Set to True to skip connection check at time of upload. Publishing + will succeed but unchecked connection issues may result in a + non-functioning workbook. Defaults to False. + + Raises + ------ + OSError + If the file path does not lead to an existing file. + + ServerResponseError + If the server response is not successful. + + TypeError + If the file is not a file path or file object. + + ValueError + If the file extension is not supported + + ValueError + If the mode is invalid. + + ValueError + Workbooks cannot be appended. + + Returns + ------- + WorkbookItem | JobItem + The workbook item or job item that was published. + """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise IOError(error) + raise OSError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -346,12 +784,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = "Unsupported file type {}!".format(file_type) + error = f"Unsupported file type {file_type}!" raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = "{}.{}".format(workbook_item.name, file_extension) + filename = f"{workbook_item.name}.{file_extension}" file_size = get_file_object_size(file) else: @@ -362,30 +800,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = "{0}?workbookType={1}".format(self.baseurl, file_extension) + url = f"{self.baseurl}?workbookType={file_extension}" if mode == self.parent_srv.PublishMode.Overwrite: - url += "&{0}=true".format(mode.lower()) + url += f"&{mode.lower()}=true" elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{0}=true".format("asJob") + url += "&{}=true".format("asJob") if skip_connection_check: - url += "&{0}=true".format("skipConnectionCheck") + url += "&{}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) + logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") upload_session_id = self.parent_srv.fileuploads.upload(file) - url = "{0}&uploadSessionId={1}".format(url, upload_session_id) + url = f"{url}&uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info("Publishing {0} to server".format(filename)) + logger.info(f"Publishing {filename} to server") if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -403,7 +841,7 @@ def publish( file_contents, connections=connections, ) - logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) + logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") # Send the publishing request to server try: @@ -415,16 +853,38 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) + logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) + logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") return new_workbook # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: + """ + Populates (or gets) a list of revisions for a workbook. + + You must first call this method to populate revisions before you can + iterate through the revisions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item to populate revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the workbook item is missing an ID. + """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -433,12 +893,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) + logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> List[RevisionItem]: - url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) + ) -> list[RevisionItem]: + url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -452,13 +912,47 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a workbook revision to the specified directory (optional). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str | None + The revision number of the workbook. If None, the latest revision is + downloaded. + + filepath : Path or File object, optional + Downloads the file to the location you specify. If no location is + specified, the file is downloaded to the current working directory. + The default is Filepath=None. + + include_extract : bool, default True + Set to False to exclude the extract from the download. The default + is True. + + Returns + ------- + Path or File object + The path to the downloaded workbook or the file object. + + Raises + ------ + ValueError + If the workbook ID is not defined. + """ + if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = "{0}/{1}/content".format(self.baseurl, workbook_id) + url = f"{self.baseurl}/{workbook_id}/content" else: - url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) + url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" if not include_extract: url += "?includeExtract=False" @@ -480,37 +974,129 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info( - "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) - ) + logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: + """ + Deletes a specific revision from a workbook on Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision + + Parameters + ---------- + workbook_id : str + The workbook ID. + + revision_number : str + The revision number of the workbook to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the workbook ID or revision number is not defined. + """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) + logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> List["AddResponse"]: # actually should return a task + ) -> list["AddResponse"]: # actually should return a task + """ + Adds a workbook to a schedule for extract refresh. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + + Parameters + ---------- + schedule_id : str + The schedule ID. + + item : WorkbookItem + The workbook item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to a workbook. One or more tags may be added at a time. If a + tag already exists on the workbook, it will not be duplicated. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to add tags to. + + tags : Iterable[str] | str + The tag or tags to add to the workbook. Tags can be a single tag or + a list of tags. + + Returns + ------- + set[str] + The set of tags added to the workbook. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from a workbook. One or more tags may be deleted at a time. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook + + Parameters + ---------- + item : WorkbookItem | str + The workbook item or workbook ID to delete tags from. + + tags : Iterable[str] | str + The tag or tags to delete from the workbook. Tags can be a single + tag or a list of tags. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: + """ + Updates the tags on a workbook. This method is used to update the tags + on the server to match the tags on the workbook item. This method is a + convenience method that calls add_tags and delete_tags to update the + tags on the server. + + Parameters + ---------- + item : WorkbookItem + The workbook item to update the tags for. The tags on the workbook + item will be used to update the tags on the server. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index b936ceb92..fd90e281f 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter(object): +class Filter: def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return "{0}:{1}:{2}".format(self.field, self.operator, value_string) + return f"{self.field}:{self.operator}:{value_string}" @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ca9d83872..e6d261b61 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,7 @@ import copy from functools import partial -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -11,14 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: - ... + def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: - ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -27,7 +26,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (List[ModelItem], PaginationItem). + Will loop over anything that returns (list[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index bbca612e9..801ad4a13 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,8 +1,10 @@ -from collections.abc import Sized +from collections.abc import Iterable, Iterator, Sized from itertools import count -from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -34,10 +36,36 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): + """ + QuerySet is a class that allows easy filtering, sorting, and iterating over + many endpoints in TableauServerClient. It is designed to be used in a similar + way to Django QuerySets, but with a more limited feature set. + + QuerySet is an iterable, and can be used in for loops, list comprehensions, + and other places where iterables are expected. + + QuerySet is also Sized, and can be used in places where the length of the + QuerySet is needed. The length of the QuerySet is the total number of items + available in the QuerySet, not just the number of items that have been + fetched. If the endpoint does not return a total count of items, the length + of the QuerySet will be sys.maxsize. If there is no total count, the + QuerySet will continue to fetch items until there are no more items to + fetch. + + QuerySet is not re-entrant. It is not designed to be used in multiple places + at the same time. If you need to use a QuerySet in multiple places, you + should create a new QuerySet for each place you need to use it, convert it + to a list, or create a deep copy of the QuerySet. + + QuerySets are also indexable, and can be sliced. If you try to access an + index that has not been fetched, the QuerySet will fetch the page that + contains the item you are looking for. + """ + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: List[T] = [] + self._result_cache: list[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -49,19 +77,30 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._fetch_all() + self._pagination_item._page_number = None + try: + self._fetch_all() + except ServerResponseError as e: + if e.code == "400006": + # If the endpoint does not support pagination, it will end + # up overrunning the total number of pages. Catch the + # error and break out of the loop. + raise StopIteration + if len(self._result_cache) == 0: + return yield from self._result_cache - # Set result_cache to empty so the fetch will populate - if (page * self.page_size) >= len(self): + # If the length of the QuerySet is unknown, continue fetching until + # the result cache is empty. + if (size := len(self)) == 0: + continue + if (page * self.page_size) >= size: return @overload - def __getitem__(self: Self, k: Slice) -> List[T]: - ... + def __getitem__(self: Self, k: Slice) -> list[T]: ... @overload - def __getitem__(self: Self, k: int) -> T: - ... + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): page = self.page_number @@ -103,6 +142,7 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] + self._pagination_item._page_number = None # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -114,11 +154,16 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache: - self._result_cache, self._pagination_item = self.model.get(self.request_options) + if not self._result_cache and self._pagination_item._page_number is None: + response = self.model.get(self.request_options) + if isinstance(response, tuple): + self._result_cache, self._pagination_item = response + else: + self._result_cache = response + self._pagination_item = PaginationItem() def __len__(self: Self) -> int: - return self.total_available + return sys.maxsize if self.total_available is None else self.total_available @property def total_available(self: Self) -> int: @@ -128,12 +173,16 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_number + # If the PaginationItem is not returned from the endpoint, use the + # pagenumber from the RequestOptions. + return self._pagination_item.page_number or self.request_options.pagenumber @property def page_size(self: Self) -> int: self._fetch_all() - return self._pagination_item.page_size + # If the PaginationItem is not returned from the endpoint, use the + # pagesize from the RequestOptions. + return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -160,22 +209,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError("Operator `{}` is not valid.".format(operator)) + raise ValueError(f"Operator `{operator}` is not valid.") field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError("Field name `{}` is not valid.".format(field)) + raise ValueError(f"Field name `{field}` is not valid.") return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(key: str) -> tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 96fa14680..f7bd139d7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from collections.abc import Iterable from typing_extensions import ParamSpec @@ -15,7 +16,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: Dict) -> Tuple[Any, str]: +def _add_multipart(parts: dict) -> tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -80,7 +81,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest(object): +class AuthRequest: def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -104,7 +105,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest(object): +class ColumnRequest: def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -115,7 +116,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest(object): +class DataAlertRequest: def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -140,7 +141,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest(object): +class DatabaseRequest: def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -159,7 +160,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest(object): +class DatasourceRequest: def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -244,7 +245,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest(object): +class DQWRequest: def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -274,7 +275,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest(object): +class FavoriteRequest: def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -329,7 +330,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest(object): +class FileuploadRequest: def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -338,8 +339,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest(object): - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: +class FlowRequest: + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -370,8 +371,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[List["ConnectionItem"]] = None, - ) -> Tuple[Any, str]: + connections: Optional[list["ConnectionItem"]] = None, + ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -380,14 +381,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest(object): +class GroupRequest: def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -477,7 +478,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest(object): +class PermissionRequest: def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -499,7 +500,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest(object): +class ProjectRequest: def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -530,7 +531,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest(object): +class ScheduleRequest: def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -609,7 +610,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest(object): +class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -848,7 +849,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest(object): +class TableRequest: def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -871,7 +872,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest(object): +class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -881,7 +882,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -897,7 +898,7 @@ def batch_create(self, element: ET.Element, tags: Set[str], content: content_typ return ET.tostring(element) -class UserRequest(object): +class UserRequest: def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -931,7 +932,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest(object): +class WorkbookRequest: def _generate_xml( self, workbook_item, @@ -995,9 +996,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib[ - "frequency" - ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["frequency"] = ( + data_freshness_policy_config.fresh_every_schedule.frequency + ) fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1075,7 +1076,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection(object): +class Connection: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1098,7 +1099,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest(object): +class TaskRequest: @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1137,7 +1138,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest(object): +class FlowTaskRequest: @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1171,7 +1172,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest(object): +class SubscriptionRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1235,13 +1236,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest(object): +class EmptyRequest: @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest(object): +class WebhookRequest: @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1287,7 +1288,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest(object): +class CustomViewRequest: @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1415,7 +1416,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory(object): +class RequestFactory: Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index ddb45834d..d79ac7f73 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,4 +1,5 @@ import sys +from typing import Optional from typing_extensions import Self @@ -9,12 +10,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase(object): +class RequestOptionsBase: # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = ["{}={}".format(k, v) for (k, v) in params.items()] + params_list = [f"{k}={v}" for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -22,15 +23,52 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{0}?{1}".format(url, "&".join(params_list)) + return "{}?{}".format(url, "&".join(params_list)) except NotImplementedError: raise - def get_query_params(self): - raise NotImplementedError() + +# If it wasn't a breaking change, I'd rename it to QueryOptions +""" +This class manages options can be used when querying content on the server +""" class RequestOptions(RequestOptionsBase): + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False + + def get_query_params(self) -> dict: + params = {} + if self.sort and len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + return params + + def page_size(self, page_size): + self.pagesize = page_size + return self + + def page_number(self, page_number): + self.pagenumber = page_number + return self + class Operator: Equals = "eq" GreaterThan = "gt" @@ -41,6 +79,7 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" + # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -117,60 +156,53 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - def page_size(self, page_size): - self.pagesize = page_size - return self - - def page_number(self, page_number): - self.pagenumber = page_number - return self +""" +These options can be used by methods that are fetching data exported from a specific content item +""" - def get_query_params(self): - params = {} - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - if len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - return params +class _DataExportOptions(RequestOptionsBase): + def __init__(self, maxage: int = -1): + super().__init__() + self.view_filters: list[tuple[str, str]] = [] + self.view_parameters: list[tuple[str, str]] = [] + self.max_age: Optional[int] = maxage + """ + This setting will affect the contents of the workbook as they are exported. + Valid language values are tableau-supported languages like de, es, en + If no locale is specified, the default locale for that language will be used + """ + self.language: Optional[str] = None -class _FilterOptionsBase(RequestOptionsBase): - """Provide a basic implementation of adding view filters to the url""" + @property + def max_age(self) -> int: + return self._max_age - def __init__(self): - self.view_filters = [] - self.view_parameters = [] + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - raise NotImplementedError() + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + if self.language: + params["language"] = self.language + + self._append_view_filters(params) + return params def vf(self, name: str, value: str) -> Self: - """Apply a filter to the view for a filter that is a normal column - within the view.""" + """Apply a filter based on a column within the view. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook.""" + """Apply a filter based on a parameter within the workbook. + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" self.view_parameters.append((name, value)) return self @@ -181,82 +213,73 @@ def _append_view_filters(self, params) -> None: params[name] = value -class CSVRequestOptions(_FilterOptionsBase): - def __init__(self, maxage=-1): - super(CSVRequestOptions, self).__init__() - self.max_age = maxage +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 max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value + def viz_height(self): + return self._viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + @viz_height.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_height(self, value): + self._viz_height = value - self._append_view_filters(params) - return params + @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 -class ExcelRequestOptions(_FilterOptionsBase): - def __init__(self, maxage: int = -1) -> None: - super().__init__() - self.max_age = maxage + def get_query_params(self) -> dict: + params = super().get_query_params() - @property - def max_age(self) -> int: - return self._max_age + # 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") - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value: int) -> None: - self._max_age = value + if self.viz_height is not None: + params["vizHeight"] = self.viz_height - def get_query_params(self): - params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age + if self.viz_width is not None: + params["vizWidth"] = self.viz_width - self._append_view_filters(params) return params -class ImageRequestOptions(_FilterOptionsBase): +class CSVRequestOptions(_DataExportOptions): + extension = "csv" + + +class ExcelRequestOptions(_DataExportOptions): + extension = "xlsx" + + +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(ImageRequestOptions, self).__init__() + 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 - self.max_age = maxage - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value def get_query_params(self): - params = {} + params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution - if self.max_age != -1: - params["maxAge"] = self.max_age - self._append_view_filters(params) return params -class PDFRequestOptions(_FilterOptionsBase): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -278,61 +301,16 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super(PDFRequestOptions, self).__init__() + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.page_type = page_type self.orientation = orientation - self.max_age = maxage - self.viz_height = viz_height - self.viz_width = viz_width - - @property - def max_age(self): - return self._max_age - - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value - - @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): - params = {} + def get_query_params(self) -> dict: + params = super().get_query_params() if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation - if self.max_age != -1: - params["maxAge"] = self.max_age - - # 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 - - self._append_view_filters(params) - return params diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index e563a7138..4eeefcaf9 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,8 +58,64 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server(object): +class Server: + """ + In the Tableau REST API, the server (https://MY-SERVER/) is the base or core + of the URI that makes up the various endpoints or methods for accessing + resources on the server (views, workbooks, sites, users, data sources, etc.) + The TSC library provides a Server class that represents the server. You + create a server instance to sign in to the server and to call the various + methods for accessing resources. + + The Server class contains the attributes that represent the server on + Tableau Server. After you create an instance of the Server class, you can + sign in to the server and call methods to access all of the resources on the + server. + + Parameters + ---------- + server_address : str + Specifies the address of the Tableau Server or Tableau Cloud (for + example, https://MY-SERVER/). + + use_server_version : bool + Specifies the version of the REST API to use (for example, '2.5'). When + you use the TSC library to call methods that access Tableau Server, the + version is passed to the endpoint as part of the URI + (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports + specific versions of the REST API. New versions of the REST API are + released with Tableau Server. By default, the value of version is set to + '2.3', which corresponds to Tableau Server 10.0. You can view or set + this value. You might need to set this to a different value, for + example, if you want to access features that are supported by the server + and a later version of the REST API. For more information, see REST API + Versions. + + Examples + -------- + >>> import tableauserverclient as TSC + + >>> # create a instance of server + >>> server = TSC.Server('https://MY-SERVER') + + >>> # sign in, etc. + + >>> # change the REST API version to match the server + >>> server.use_server_version() + + >>> # or change the REST API version to match a specific version + >>> # for example, 2.8 + >>> # server.version = '2.8' + + """ + class PublishMode: + """ + Enumerates the options that specify what happens when you publish a + workbook or data source. The options are Overwrite, Append, or + CreateNew. + """ + Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" @@ -130,7 +186,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return "".format(self.baseurl, self.server_info.serverInfo) + return f"" def add_http_options(self, options_dict: dict): try: @@ -142,7 +198,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError("Invalid http options given: {}".format(options_dict)) + raise ValueError(f"Invalid http options given: {options_dict}") def clear_http_options(self): self._http_options = dict() @@ -176,15 +232,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = self._get_legacy_version() except Exception as e: - logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) + logger.info(f"Could not get version info from server: {e.__class__}{e}") version = None - logger.info("versions: {}, {}".format(version, old_version)) + logger.info(f"versions: {version}, {old_version}") return version or old_version def use_server_version(self): @@ -201,12 +257,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) + error = f"{reason} is not available in API version {self.version}. Requires {comparison}" raise EndpointUnavailableError(error) @property def baseurl(self): - return "{0}/api/{1}".format(self._server_address, str(self.version)) + return f"{self._server_address}/api/{str(self.version)}" @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 2d6bc030a..839a8c8db 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort(object): +class Sort: def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return "{0}:{1}".format(self.field, self.direction) + return f"{self.field}:{self.direction}" diff --git a/test/_utils.py b/test/_utils.py index 8527aaf8c..b4ee93bc3 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -18,6 +19,19 @@ def read_xml_assets(*args): return map(read_xml_asset, args) +def server_response_error_factory(code: str, summary: str, detail: str) -> str: + root = ET.Element("tsResponse") + error = ET.SubElement(root, "error") + error.attrib["code"] = code + + summary_element = ET.SubElement(error, "summary") + summary_element.text = summary + + detail_element = ET.SubElement(error, "detail") + detail_element.text = detail + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index bdce4cdfb..489e8ac63 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,5 +1,4 @@ - - \ No newline at end of file + diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html new file mode 100644 index 000000000..e92daeb2d --- /dev/null +++ b/test/assets/server_info_wrong_site.html @@ -0,0 +1,56 @@ + + + + + + Example website + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDE
12345
23456
34567
45678
56789
+ + + \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index eaf13481e..48100ad88 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: 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) diff --git a/test/test_dataalert.py b/test/test_dataalert.py index d9e00a9db..6f6f1683c 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) + m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 624eb93e1..45d9ba9c9 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) + self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) + self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) + m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) + m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 8635af978..ff1ef0f72 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse(object): + class FakeResponse: headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 6f0be3b3c..87332d70f 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) + m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) + m.put(f"{baseurl}/{self.user.id}", text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) + m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) + m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) + m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) + m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 4c8fb0f9f..0f3234d5d 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write("This is a zip file".encode()) + stream.write(b"This is a zip file") zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 50a5ef48b..9567bc3ad 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) + m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 864c0d3cd..8af2540dc 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,3 +1,4 @@ +import sys import unittest import requests_mock @@ -5,7 +6,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time +from ._utils import read_xml_asset, mocked_time, server_response_error_factory GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -28,9 +29,8 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs, pagination_item = self.server.flow_runs.get() + all_flow_runs = self.server.flow_runs.get() - self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,6 +95,17 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) + m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + def test_queryset(self) -> None: + response_xml = read_xml_asset(GET_XML) + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{self.baseurl}?pageNumber=2", text=error_response) + queryset = self.server.flow_runs.all() + assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 034066e64..2d9f7c7bd 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index fc9c75a6d..41b5992be 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,4 +1,3 @@ -# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index d86397086..20b238764 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) + m.get(f"{self.baseurl}/{job_id}", text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index c30352809..1836095bb 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,6 +1,7 @@ import contextlib import os import unittest +import xml.etree.ElementTree as ET import requests_mock @@ -122,3 +123,14 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None + + def test_queryset_no_matches(self) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.groups.baseurl, text=xml) + all_groups = self.server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 diff --git a/test/test_project.py b/test/test_project.py index e05785f86..430db84b2 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) - m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) - m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 772704f69..62e301591 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,9 +1,5 @@ import unittest - -try: - from unittest import mock -except ImportError: - import mock # type: ignore[no-redef] +from unittest import mock import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index e48f8510a..7405189a3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) + self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual(set(["weather"]), matching_workbooks[0].tags) - self.assertEqual(set(["safari"]), matching_workbooks[1].tags) - self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + self.assertEqual({"weather"}, matching_workbooks[0].tags) + self.assertEqual({"safari"}, matching_workbooks[1].tags) + self.assertEqual({"sample"}, matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -358,3 +358,13 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) + + def test_language_export(self) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = self.server.users.get_request(url, request_object=opts) + self.assertTrue(re.search("language=en-us", resp.request.query)) diff --git a/test/test_schedule.py b/test/test_schedule.py index 0377295d7..b072522a4 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index 1cf190ecd..fa1472c9a 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,6 +4,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -11,6 +12,7 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") +SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -63,3 +65,11 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") + + def test_server_wrong_site(self): + with open(SERVER_INFO_WRONG_SITE, "rb") as f: + response = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.server_info.baseurl, text=response, status_code=404) + with self.assertRaises(NonXMLResponseError): + self.server.server_info.get() diff --git a/test/test_site_model.py b/test/test_site_model.py index f62eb66f0..60ad9c5e5 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,5 +1,3 @@ -# coding=utf-8 - import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 0184af415..23dffebfb 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from typing import Iterable +from collections.abc import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = set(["x", "y", "z"]) + initial_tags = {"x", "y", "z"} item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 53da7c160..2d724b879 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) + m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) + m.get(f"{self.baseurl}/{task_id}", text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) + m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post("{}".format(self.baseurl), text=response_xml) + m.post(f"{self.baseurl}", text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index 1f5eba57f..a46624845 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,5 @@ -import io import os import unittest -from typing import List -from unittest.mock import MagicMock import requests_mock @@ -163,7 +160,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) + self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -176,7 +173,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) + m.get(f"{baseurl}/{single_user.id}", text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index d0997b9ff..a8a2c51cb 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,7 +1,6 @@ import logging import unittest from unittest.mock import * -from typing import List import io import pytest @@ -107,7 +106,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -119,10 +118,10 @@ def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) - assert invalid == [], "Expected no failures, got {}".format(invalid) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" diff --git a/test/test_view.py b/test/test_view.py index 1c667a4c3..a89a6d235 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) + self.assertEqual({"tag1", "tag2"}, all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual({"tag1", "tag2"}, view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 6f94f0c10..766831b0a 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) diff --git a/test/test_workbook.py b/test/test_workbook.py index 950118dc0..1a6b3192f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) + self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual({"Safari", "Sample"}, single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) + disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) + m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) + m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index 86c240e13..cce899f58 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,7 +276,6 @@ """ -from __future__ import print_function try: import configparser @@ -328,7 +327,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) + print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") except NameError: pass return root @@ -342,7 +341,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg, "r") as f: + with open(setup_cfg) as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -398,7 +397,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except EnvironmentError: + except OSError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -408,7 +407,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print("unable to find command, tried %s" % (commands,)) + print(f"unable to find command, tried {commands}" return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -423,7 +422,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = ''' +] = r''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -955,7 +954,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs, "r") + f = open(versionfile_abs) for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -970,7 +969,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except EnvironmentError: + except OSError: pass return keywords @@ -994,11 +993,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1007,7 +1006,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r"\d", r)]) + tags = {r for r in refs if re.search(r"\d", r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1100,7 +1099,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) + pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1145,13 +1144,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes", "r") + f = open(".gitattributes") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except EnvironmentError: + except OSError: pass if not present: f = open(".gitattributes", "a+") @@ -1185,7 +1184,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) + print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1212,7 +1211,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except EnvironmentError: + except OSError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1229,7 +1228,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print("set %s to '%s'" % (filename, versions["version"])) + print(f"set {filename} to '{versions['version']}'") def plus_or_dot(pieces): @@ -1452,7 +1451,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print("got version from file %s %s" % (versionfile_abs, ver)) + print(f"got version from file {versionfile_abs} {ver}") return ver except NotThisMethod: pass @@ -1723,7 +1722,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1748,9 +1747,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy, "r") as f: + with open(ipy) as f: old = f.read() - except EnvironmentError: + except OSError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1769,12 +1768,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in, "r") as f: + with open(manifest_in) as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except EnvironmentError: + except OSError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1805,7 +1804,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py", "r") as f: + with open("setup.py") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 196d73a08035cc0d8be243d8f9c76f4265b36057 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Thu, 24 Oct 2024 13:42:25 -0700 Subject: [PATCH 531/567] Revert "Development to master branch merge" (#1513) Revert "0.34 development merge" This reverts commit 6798b9e5fc27d459fea93b415519331c7adac954. --- .github/workflows/meta-checks.yml | 14 - .github/workflows/run-tests.yml | 16 +- pyproject.toml | 18 +- samples/add_default_permission.py | 4 +- samples/create_group.py | 13 +- samples/create_project.py | 2 +- samples/create_schedules.py | 8 +- samples/explore_datasource.py | 22 +- samples/explore_favorites.py | 16 +- samples/explore_site.py | 2 +- samples/explore_webhooks.py | 4 +- samples/explore_workbook.py | 33 +- samples/export.py | 23 +- samples/extracts.py | 14 +- samples/filter_sort_groups.py | 44 +- samples/filter_sort_projects.py | 2 +- samples/getting_started/1_hello_server.py | 4 +- samples/getting_started/2_hello_site.py | 4 +- samples/getting_started/3_hello_universe.py | 22 +- samples/initialize_server.py | 10 +- samples/list.py | 5 +- samples/login.py | 25 +- samples/move_workbook_sites.py | 8 +- samples/pagination_sample.py | 8 +- samples/publish_datasource.py | 25 +- samples/publish_workbook.py | 4 +- samples/query_permissions.py | 8 +- samples/refresh_tasks.py | 4 +- samples/set_refresh_schedule.py | 2 +- samples/update_connection.py | 2 +- samples/update_workbook_data_acceleration.py | 109 +++ .../update_workbook_data_freshness_policy.py | 2 +- tableauserverclient/__init__.py | 50 +- tableauserverclient/_version.py | 18 +- tableauserverclient/config.py | 8 +- tableauserverclient/models/column_item.py | 2 +- .../models/connection_credentials.py | 2 +- tableauserverclient/models/connection_item.py | 12 +- .../models/custom_view_item.py | 35 +- .../models/data_acceleration_report_item.py | 4 +- tableauserverclient/models/data_alert_item.py | 10 +- .../models/data_freshness_policy_item.py | 12 +- tableauserverclient/models/database_item.py | 6 +- tableauserverclient/models/datasource_item.py | 20 +- tableauserverclient/models/dqw_item.py | 2 +- tableauserverclient/models/favorites_item.py | 11 +- tableauserverclient/models/fileupload_item.py | 2 +- tableauserverclient/models/flow_item.py | 12 +- tableauserverclient/models/flow_run_item.py | 6 +- tableauserverclient/models/group_item.py | 8 +- tableauserverclient/models/groupset_item.py | 8 +- tableauserverclient/models/interval_item.py | 18 +- tableauserverclient/models/job_item.py | 16 +- .../models/linked_tasks_item.py | 10 +- tableauserverclient/models/metric_item.py | 10 +- tableauserverclient/models/pagination_item.py | 2 +- .../models/permissions_item.py | 22 +- tableauserverclient/models/project_item.py | 54 +- .../models/property_decorators.py | 23 +- tableauserverclient/models/reference_item.py | 4 +- tableauserverclient/models/revision_item.py | 6 +- tableauserverclient/models/schedule_item.py | 4 +- .../models/server_info_item.py | 32 +- tableauserverclient/models/site_item.py | 72 +- .../models/subscription_item.py | 6 +- tableauserverclient/models/table_item.py | 2 +- tableauserverclient/models/tableau_auth.py | 120 +-- tableauserverclient/models/tableau_types.py | 4 +- tableauserverclient/models/tag_item.py | 7 +- tableauserverclient/models/task_item.py | 8 +- tableauserverclient/models/user_item.py | 64 +- tableauserverclient/models/view_item.py | 21 +- .../models/virtual_connection_item.py | 11 +- tableauserverclient/models/webhook_item.py | 12 +- tableauserverclient/models/workbook_item.py | 102 +-- tableauserverclient/namespace.py | 2 +- tableauserverclient/server/__init__.py | 3 +- .../server/endpoint/auth_endpoint.py | 73 +- .../server/endpoint/custom_views_endpoint.py | 80 +- .../data_acceleration_report_endpoint.py | 4 +- .../server/endpoint/data_alert_endpoint.py | 28 +- .../server/endpoint/databases_endpoint.py | 25 +- .../server/endpoint/datasources_endpoint.py | 103 +-- .../endpoint/default_permissions_endpoint.py | 37 +- .../server/endpoint/dqw_endpoint.py | 18 +- .../server/endpoint/endpoint.py | 40 +- .../server/endpoint/exceptions.py | 30 +- .../server/endpoint/favorites_endpoint.py | 62 +- .../server/endpoint/fileuploads_endpoint.py | 20 +- .../server/endpoint/flow_runs_endpoint.py | 28 +- .../server/endpoint/flow_task_endpoint.py | 4 +- .../server/endpoint/flows_endpoint.py | 59 +- .../server/endpoint/groups_endpoint.py | 35 +- .../server/endpoint/groupsets_endpoint.py | 4 +- .../server/endpoint/jobs_endpoint.py | 14 +- .../server/endpoint/linked_tasks_endpoint.py | 4 +- .../server/endpoint/metadata_endpoint.py | 4 +- .../server/endpoint/metrics_endpoint.py | 20 +- .../server/endpoint/permissions_endpoint.py | 28 +- .../server/endpoint/projects_endpoint.py | 111 +-- .../server/endpoint/resource_tagger.py | 27 +- .../server/endpoint/schedules_endpoint.py | 35 +- .../server/endpoint/server_info_endpoint.py | 45 +- .../server/endpoint/sites_endpoint.py | 299 +------- .../server/endpoint/subscriptions_endpoint.py | 20 +- .../server/endpoint/tables_endpoint.py | 29 +- .../server/endpoint/tasks_endpoint.py | 16 +- .../server/endpoint/users_endpoint.py | 385 +--------- .../server/endpoint/views_endpoint.py | 37 +- .../endpoint/virtual_connections_endpoint.py | 11 +- .../server/endpoint/webhooks_endpoint.py | 22 +- .../server/endpoint/workbooks_endpoint.py | 708 ++---------------- tableauserverclient/server/filter.py | 4 +- tableauserverclient/server/pager.py | 11 +- tableauserverclient/server/query.py | 87 +-- tableauserverclient/server/request_factory.py | 73 +- tableauserverclient/server/request_options.py | 268 ++++--- tableauserverclient/server/server.py | 74 +- tableauserverclient/server/sort.py | 4 +- test/_utils.py | 14 - test/assets/flow_runs_get.xml | 3 +- test/assets/server_info_wrong_site.html | 56 -- test/test_auth.py | 6 +- test/test_custom_view.py | 72 -- test/test_dataalert.py | 2 +- test/test_datasource.py | 10 +- test/test_endpoint.py | 2 +- test/test_favorites.py | 18 +- test/test_filesys_helpers.py | 2 +- test/test_fileuploads.py | 6 +- test/test_flowruns.py | 23 +- test/test_flowtask.py | 2 +- test/test_group.py | 1 + test/test_job.py | 8 +- test/test_pager.py | 12 - test/test_project.py | 36 +- test/test_regression_tests.py | 6 +- test/test_request_option.py | 24 +- test/test_schedule.py | 18 +- test/test_server_info.py | 10 - test/test_site_model.py | 2 + test/test_tagging.py | 4 +- test/test_task.py | 8 +- test/test_user.py | 7 +- test/test_user_model.py | 9 +- test/test_view.py | 6 +- test/test_view_acceleration.py | 2 +- test/test_workbook.py | 12 +- versioneer.py | 47 +- 149 files changed, 1407 insertions(+), 3347 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py delete mode 100644 test/assets/server_info_wrong_site.html diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 0e2b425ee..41a944e63 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,20 +13,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Get pip cache dir - id: pip-cache - shell: bash - run: | - echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - - - name: cache - uses: actions/cache@v4 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2e197cf20..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,25 +13,11 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} steps: - - name: Get pip cache dir - id: pip-cache - shell: bash - run: | - echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - - - name: cache - uses: actions/cache@v4 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} diff --git a/pyproject.toml b/pyproject.toml index 08f90c49c..3bf47ea23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,42 +14,42 @@ readme = "README.md" dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 - 'requests>=2.32', # latest as at 7/31/23 - 'urllib3>=2.2.2,<3', + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.2.2', # dependabot 'typing_extensions>=4.0.1', ] -requires-python = ">=3.9" +requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] check_untyped_defs = false disable_error_code = [ 'misc', + # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] 'annotation-unchecked' # can be removed when check_untyped_defs = true ] -files = ["tableauserverclient", "test", "samples"] +files = ["tableauserverclient", "test"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true -implicit_optional = true [tool.pytest.ini_options] testpaths = ["test"] diff --git a/samples/add_default_permission.py b/samples/add_default_permission.py index d26d009e2..5a450e8ab 100644 --- a/samples/add_default_permission.py +++ b/samples/add_default_permission.py @@ -63,10 +63,10 @@ def main(): for permission in new_default_permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) # Uncomment lines below to DELETE the new capability and the new project # rules_to_delete = TSC.PermissionsRule( diff --git a/samples/create_group.py b/samples/create_group.py index aca3e895b..f4c6a9ca9 100644 --- a/samples/create_group.py +++ b/samples/create_group.py @@ -11,6 +11,7 @@ import os from datetime import time +from typing import List import tableauserverclient as TSC from tableauserverclient import ServerResponseError @@ -62,23 +63,23 @@ def main(): if args.file: filepath = os.path.abspath(args.file) - print(f"Add users to site from file {filepath}:") - added: list[TSC.UserItem] - failed: list[TSC.UserItem, TSC.ServerResponseError] + print("Add users to site from file {}:".format(filepath)) + added: List[TSC.UserItem] + failed: List[TSC.UserItem, TSC.ServerResponseError] added, failed = server.users.create_from_file(filepath) for user, error in failed: print(user, error.code) if error.code == "409017": user = server.users.filter(name=user.name)[0] added.append(user) - print(f"Adding users to group:{added}") + print("Adding users to group:{}".format(added)) for user in added: - print(f"Adding user {user}") + print("Adding user {}".format(user)) try: server.groups.add_user(group, user.id) except ServerResponseError as serverError: if serverError.code == "409011": - print(f"user {user.name} is already a member of group {group.name}") + print("user {} is already a member of group {}".format(user.name, group.name)) else: raise rError diff --git a/samples/create_project.py b/samples/create_project.py index d775902aa..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -84,7 +84,7 @@ def main(): server.projects.populate_datasource_default_permissions(changed_project), server.projects.populate_permissions(changed_project) # Projects have default permissions set for the object types they contain - print(f"Permissions from project {changed_project.id}:") + print("Permissions from project {}:".format(changed_project.id)) print(changed_project.permissions) print( changed_project.default_workbook_permissions, diff --git a/samples/create_schedules.py b/samples/create_schedules.py index c23a2eced..dee088571 100644 --- a/samples/create_schedules.py +++ b/samples/create_schedules.py @@ -55,7 +55,7 @@ def main(): ) try: hourly_schedule = server.schedules.create(hourly_schedule) - print(f"Hourly schedule created (ID: {hourly_schedule.id}).") + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) except Exception as e: print(e) @@ -71,7 +71,7 @@ def main(): ) try: daily_schedule = server.schedules.create(daily_schedule) - print(f"Daily schedule created (ID: {daily_schedule.id}).") + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) except Exception as e: print(e) @@ -89,7 +89,7 @@ def main(): ) try: weekly_schedule = server.schedules.create(weekly_schedule) - print(f"Weekly schedule created (ID: {weekly_schedule.id}).") + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) except Exception as e: print(e) options = TSC.RequestOptions() @@ -112,7 +112,7 @@ def main(): ) try: monthly_schedule = server.schedules.create(monthly_schedule) - print(f"Monthly schedule created (ID: {monthly_schedule.id}).") + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) except Exception as e: print(e) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..fb45cb45e 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -51,17 +51,16 @@ def main(): if args.publish: if default_project is not None: new_datasource = TSC.DatasourceItem(default_project.id) - new_datasource.description = "Published with a description" new_datasource = server.datasources.publish( new_datasource, args.publish, TSC.Server.PublishMode.Overwrite ) - print(f"Datasource published. ID: {new_datasource.id}") + print("Datasource published. ID: {}".format(new_datasource.id)) else: print("Publish failed. Could not find the default project.") # Gets all datasource items all_datasources, pagination_item = server.datasources.get() - print(f"\nThere are {pagination_item.total_available} datasources on site: ") + print("\nThere are {} datasources on site: ".format(pagination_item.total_available)) print([datasource.name for datasource in all_datasources]) if all_datasources: @@ -70,19 +69,20 @@ def main(): # Populate connections server.datasources.populate_connections(sample_datasource) - print(f"\nConnections for {sample_datasource.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_datasource.connections]) - - # Demonstrate that description is editable - sample_datasource.description = "Description updated by TSC" - server.datasources.update(sample_datasource) + print("\nConnections for {}: ".format(sample_datasource.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_datasource.connections + ] + ) # Add some tags to the datasource original_tag_set = set(sample_datasource.tags) sample_datasource.tags.update("a", "b", "c", "d") server.datasources.update(sample_datasource) - print(f"\nOld tag set: {original_tag_set}") - print(f"New tag set: {sample_datasource.tags}") + print("\nOld tag set: {}".format(original_tag_set)) + print("New tag set: {}".format(sample_datasource.tags)) # Delete all tags that were added by setting tags to original sample_datasource.tags = original_tag_set diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..243e91954 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -3,7 +3,7 @@ import argparse import logging import tableauserverclient as TSC -from tableauserverclient.models import Resource +from tableauserverclient import Resource def main(): @@ -39,15 +39,15 @@ def main(): # get all favorites on site for the logged on user user: TSC.UserItem = TSC.UserItem() user.id = server.user_id - print(f"Favorites for user: {user.id}") + print("Favorites for user: {}".format(user.id)) server.favorites.get(user) print(user.favorites) # get list of workbooks all_workbook_items, pagination_item = server.workbooks.get() if all_workbook_items is not None and len(all_workbook_items) > 0: - my_workbook = all_workbook_items[0] - server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + server.favorites.add_favorite(server, user, Resource.Workbook.name(), all_workbook_items[0]) print( "Workbook added to favorites. Workbook Name: {}, Workbook ID: {}".format( my_workbook.name, my_workbook.id @@ -57,7 +57,7 @@ def main(): if views is not None and len(views) > 0: my_view = views[0] server.favorites.add_favorite_view(user, my_view) - print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View added to favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) all_datasource_items, pagination_item = server.datasources.get() if all_datasource_items: @@ -70,10 +70,12 @@ def main(): ) server.favorites.delete_favorite_workbook(user, my_workbook) - print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") + print( + "Workbook deleted from favorites. Workbook Name: {}, Workbook ID: {}".format(my_workbook.name, my_workbook.id) + ) server.favorites.delete_favorite_view(user, my_view) - print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") + print("View deleted from favorites. View Name: {}, View ID: {}".format(my_view.name, my_view.id)) server.favorites.delete_favorite_datasource(user, my_datasource) print( diff --git a/samples/explore_site.py b/samples/explore_site.py index eb9eba0de..a2274f1a7 100644 --- a/samples/explore_site.py +++ b/samples/explore_site.py @@ -49,7 +49,7 @@ def main(): if args.delete: print("You can only delete the site you are currently in") - print(f"Delete site `{current_site.name}`?") + print("Delete site `{}`?".format(current_site.name)) # server.sites.delete(server.site_id) elif args.create: diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..77802b1db 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -52,11 +52,11 @@ def main(): new_webhook.event = "datasource-created" print(new_webhook) new_webhook = server.webhooks.create(new_webhook) - print(f"Webhook created. ID: {new_webhook.id}") + print("Webhook created. ID: {}".format(new_webhook.id)) # Gets all webhook items all_webhooks, pagination_item = server.webhooks.get() - print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + print("\nThere are {} webhooks on site: ".format(pagination_item.total_available)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f51639ab3..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -59,13 +59,13 @@ def main(): if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) new_workbook = server.workbooks.publish(new_workbook, args.publish, overwrite_true) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {}".format(new_workbook.id)) else: print("Publish failed. Could not find the default project.") # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -78,22 +78,27 @@ def main(): # Populate views server.workbooks.populate_views(sample_workbook) - print(f"\nName of views in {sample_workbook.name}: ") + print("\nName of views in {}: ".format(sample_workbook.name)) print([view.name for view in sample_workbook.views]) # Populate connections server.workbooks.populate_connections(sample_workbook) - print(f"\nConnections for {sample_workbook.name}: ") - print([f"{connection.id}({connection.datasource_name})" for connection in sample_workbook.connections]) + print("\nConnections for {}: ".format(sample_workbook.name)) + print( + [ + "{0}({1})".format(connection.id, connection.datasource_name) + for connection in sample_workbook.connections + ] + ) # Update tags and show_tabs flag original_tag_set = set(sample_workbook.tags) sample_workbook.tags.update("a", "b", "c", "d") sample_workbook.show_tabs = True server.workbooks.update(sample_workbook) - print(f"\nWorkbook's old tag set: {original_tag_set}") - print(f"Workbook's new tag set: {sample_workbook.tags}") - print(f"Workbook tabbed: {sample_workbook.show_tabs}") + print("\nWorkbook's old tag set: {}".format(original_tag_set)) + print("Workbook's new tag set: {}".format(sample_workbook.tags)) + print("Workbook tabbed: {}".format(sample_workbook.show_tabs)) # Delete all tags that were added by setting tags to original sample_workbook.tags = original_tag_set @@ -104,8 +109,8 @@ def main(): original_tag_set = set(sample_view.tags) sample_view.tags.add("view_tag") server.views.update(sample_view) - print(f"\nView's old tag set: {original_tag_set}") - print(f"View's new tag set: {sample_view.tags}") + print("\nView's old tag set: {}".format(original_tag_set)) + print("View's new tag set: {}".format(sample_view.tags)) # Delete tag from just one view sample_view.tags = original_tag_set @@ -114,14 +119,14 @@ def main(): if args.download: # Download path = server.workbooks.download(sample_workbook.id, args.download) - print(f"\nDownloaded workbook to {path}") + print("\nDownloaded workbook to {}".format(path)) if args.preview_image: # Populate workbook preview image server.workbooks.populate_preview_image(sample_workbook) with open(args.preview_image, "wb") as f: f.write(sample_workbook.preview_image) - print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") + print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image))) # get custom views cvs, _ = server.custom_views.get() @@ -148,10 +153,10 @@ def main(): server.workbooks.populate_powerpoint(sample_workbook) with open(args.powerpoint, "wb") as f: f.write(sample_workbook.powerpoint) - print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) if args.delete: - print(f"deleting {c.id}") + print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) server.custom_views.delete(unlucky.id) diff --git a/samples/export.py b/samples/export.py index b2506cf46..f2783fa6e 100644 --- a/samples/export.py +++ b/samples/export.py @@ -37,11 +37,8 @@ def main(): "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") ) # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--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") @@ -59,16 +56,14 @@ 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) if not item: - print(f"No item found for id {args.resource_id}") + print("No item found for id {}".format(args.resource_id)) exit(1) - print(f"Item found: {item.name}") + print("Item found: {}".format(item.name)) # We have a number of different types and functions for each different export type. # We encode that information above in the const=(...) parameter to the add_argument function to make # the code automatically adapt for the type of export the user is doing. @@ -77,22 +72,18 @@ 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() if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language + options = option_factory().vf(*args.filter.split(":")) + else: + options = None if args.file: filename = args.file else: - filename = f"out-{options.language}.{extension}" + filename = "out.{}".format(extension) populate(item, options) with open(filename, "wb") as f: diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..9bd87a473 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -1,7 +1,13 @@ #### -# This script demonstrates how to use the Tableau Server Client to interact with extracts. -# It explores the different functions that the REST API supports on extracts. -##### +# This script demonstrates how to use the Tableau Server Client +# to interact with workbooks. It explores the different +# functions that the Server API supports on workbooks. +# +# With no flags set, this sample will query all workbooks, +# pick one workbook and populate its connections/views, and update +# the workbook. Adding flags will demonstrate the specific feature +# on top of the general operations. +#### import argparse import logging @@ -41,7 +47,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Gets all workbook items all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/samples/filter_sort_groups.py b/samples/filter_sort_groups.py index 1694bf0f5..042af32e2 100644 --- a/samples/filter_sort_groups.py +++ b/samples/filter_sort_groups.py @@ -47,7 +47,7 @@ def main(): logging.basicConfig(level=logging_level) tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): group_name = "SALES NORTHWEST" # Try to create a group named "SALES NORTHWEST" @@ -57,36 +57,37 @@ def main(): # Try to create a group named "SALES ROMANIA" create_example_group(group_name, server) - # we no longer need to encode the space + # URL Encode the name of the group that we want to filter on + # i.e. turn spaces into plus signs + filter_group_name = urllib.parse.quote_plus(group_name) options = TSC.RequestOptions() - options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, group_name)) + options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, filter_group_name) + ) filtered_groups, _ = server.groups.get(req_options=options) # Result can either be a matching group or an empty list if filtered_groups: - group = filtered_groups.pop() - print(group) + group_name = filtered_groups.pop().name + print(group_name) else: - error = f"No group named '{group_name}' found" + error = "No project named '{}' found".format(filter_group_name) print(error) - print("---") - # Or, try the above with the django style filtering try: - group = server.groups.filter(name=group_name)[0] - print(group) + group = server.groups.filter(name=filter_group_name)[0] except IndexError: - print(f"No group named '{group_name}' found") - - print("====") + print(f"No project named '{filter_group_name}' found") + else: + print(group.name) options = TSC.RequestOptions() options.filter.add( TSC.Filter( TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, - ["SALES NORTHWEST", "SALES ROMANIA", "this_group"], + ["SALES+NORTHWEST", "SALES+ROMANIA", "this_group"], ) ) @@ -97,19 +98,12 @@ def main(): for group in matching_groups: print(group.name) - print("----") # or, try the above with the django style filtering. - all_g = server.groups.all() - print(f"Searching locally among {all_g.total_available} groups") - for a in all_g: - print(a) - groups = [urllib.parse.quote_plus(group) for group in ["SALES NORTHWEST", "SALES ROMANIA", "this_group"]] - print(groups) - - for group in server.groups.filter(name__in=groups).order_by("-name"): - print(group.name) - print("done") + groups = ["SALES NORTHWEST", "SALES ROMANIA", "this_group"] + groups = [urllib.parse.quote_plus(group) for group in groups] + for group in server.groups.filter(name__in=groups).sort("-name"): + print(group.name) if __name__ == "__main__": diff --git a/samples/filter_sort_projects.py b/samples/filter_sort_projects.py index 6c3a85dcd..7aa62a5c1 100644 --- a/samples/filter_sort_projects.py +++ b/samples/filter_sort_projects.py @@ -68,7 +68,7 @@ def main(): project_name = filtered_projects.pop().name print(project_name) else: - error = f"No project named '{filter_project_name}' found" + error = "No project named '{}' found".format(filter_project_name) print(error) create_example_project(name="Example 1", server=server) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py index 5f8cfa238..454b225de 100644 --- a/samples/getting_started/1_hello_server.py +++ b/samples/getting_started/1_hello_server.py @@ -12,8 +12,8 @@ def main(): # This is the domain for Tableau's Developer Program server_url = "https://10ax.online.tableau.com" server = TSC.Server(server_url) - print(f"Connected to {server.server_info.baseurl}") - print(f"Server information: {server.server_info}") + print("Connected to {}".format(server.server_info.baseurl)) + print("Server information: {}".format(server.server_info)) print("Sign up for a test site at https://www.tableau.com/developer") diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py index 8635947a8..d62896059 100644 --- a/samples/getting_started/2_hello_site.py +++ b/samples/getting_started/2_hello_site.py @@ -19,7 +19,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + print("Connected to {}".format(server.server_info.baseurl)) # 3 - replace with your site name exactly as it looks in the url # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part @@ -39,7 +39,7 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) project = projects[0] print(project.name) diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..21de97831 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -17,7 +17,7 @@ def main(): use_ssl = True server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) - print(f"Connected to {server.server_info.baseurl}") + print("Connected to {}".format(server.server_info.baseurl)) # 3 - replace with your site name exactly as it looks in a url # e.g https://my-server/#/this-is-your-site-url-name/ @@ -36,55 +36,55 @@ def main(): with server.auth.sign_in(tableau_auth): projects, pagination = server.projects.get() if projects: - print(f"{pagination.total_available} projects") + print("{} projects".format(pagination.total_available)) for project in projects: print(project.name) workbooks, pagination = server.datasources.get() if workbooks: - print(f"{pagination.total_available} workbooks") + print("{} workbooks".format(pagination.total_available)) print(workbooks[0]) views, pagination = server.views.get() if views: - print(f"{pagination.total_available} views") + print("{} views".format(pagination.total_available)) print(views[0]) datasources, pagination = server.datasources.get() if datasources: - print(f"{pagination.total_available} datasources") + print("{} datasources".format(pagination.total_available)) print(datasources[0]) # I think all these other content types can go to a hello_universe script # data alert, dqw, flow, ... do any of these require any add-ons? jobs, pagination = server.jobs.get() if jobs: - print(f"{pagination.total_available} jobs") + print("{} jobs".format(pagination.total_available)) print(jobs[0]) schedules, pagination = server.schedules.get() if schedules: - print(f"{pagination.total_available} schedules") + print("{} schedules".format(pagination.total_available)) print(schedules[0]) tasks, pagination = server.tasks.get() if tasks: - print(f"{pagination.total_available} tasks") + print("{} tasks".format(pagination.total_available)) print(tasks[0]) webhooks, pagination = server.webhooks.get() if webhooks: - print(f"{pagination.total_available} webhooks") + print("{} webhooks".format(pagination.total_available)) print(webhooks[0]) users, pagination = server.users.get() if users: - print(f"{pagination.total_available} users") + print("{} users".format(pagination.total_available)) print(users[0]) groups, pagination = server.groups.get() if groups: - print(f"{pagination.total_available} groups") + print("{} groups".format(pagination.total_available)) print(groups[0]) diff --git a/samples/initialize_server.py b/samples/initialize_server.py index cdfaf27a8..cb3d9e1d0 100644 --- a/samples/initialize_server.py +++ b/samples/initialize_server.py @@ -51,7 +51,7 @@ def main(): # Create the site if it doesn't exist if existing_site is None: - print(f"Site not found: {args.site_id} Creating it...") + print("Site not found: {0} Creating it...".format(args.site_id)) new_site = TSC.SiteItem( name=args.site_id, content_url=args.site_id.replace(" ", ""), @@ -59,7 +59,7 @@ def main(): ) server.sites.create(new_site) else: - print(f"Site {args.site_id} exists. Moving on...") + print("Site {0} exists. Moving on...".format(args.site_id)) ################################################################################ # Step 3: Sign-in to our target site @@ -81,7 +81,7 @@ def main(): # Create our project if it doesn't exist if project is None: - print(f"Project not found: {args.project} Creating it...") + print("Project not found: {0} Creating it...".format(args.project)) new_project = TSC.ProjectItem(name=args.project) project = server_upload.projects.create(new_project) @@ -100,7 +100,7 @@ def publish_datasources_to_site(server_object, project, folder): for fname in glob.glob(path): new_ds = TSC.DatasourceItem(project.id) new_ds = server_object.datasources.publish(new_ds, fname, server_object.PublishMode.Overwrite) - print(f"Datasource published. ID: {new_ds.id}") + print("Datasource published. ID: {0}".format(new_ds.id)) def publish_workbooks_to_site(server_object, project, folder): @@ -110,7 +110,7 @@ def publish_workbooks_to_site(server_object, project, folder): new_workbook = TSC.WorkbookItem(project.id) new_workbook.show_tabs = True new_workbook = server_object.workbooks.publish(new_workbook, fname, server_object.PublishMode.Overwrite) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) if __name__ == "__main__": diff --git a/samples/list.py b/samples/list.py index 2675a2954..8d72fb620 100644 --- a/samples/list.py +++ b/samples/list.py @@ -48,9 +48,6 @@ def main(): "webhooks": server.webhooks, "workbook": server.workbooks, }.get(args.resource_type) - if endpoint is None: - print("Resource type not found.") - sys.exit(1) options = TSC.RequestOptions() options.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Desc)) @@ -62,7 +59,7 @@ def main(): print(resource.name[:18], " ") # , resource._connections()) if count > 100: break - print(f"Total: {count}") + print("Total: {}".format(count)) if __name__ == "__main__": diff --git a/samples/login.py b/samples/login.py index bc99385b3..6a3e9e8b3 100644 --- a/samples/login.py +++ b/samples/login.py @@ -7,15 +7,9 @@ import argparse import getpass import logging -import os import tableauserverclient as TSC - - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env # If a sample has additional arguments, then it should copy this code and insert them after the call to @@ -26,13 +20,13 @@ def set_up_and_log_in(): sample_define_common_options(parser) args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging_level = "debug" server = sample_connect_to_server(args) @@ -65,7 +59,7 @@ def sample_connect_to_server(args): password = args.password or getpass.getpass("Password: ") tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") + print("\nSigning in...\nServer: {}\nSite: {}\nUsername: {}".format(args.server, args.site, args.username)) else: # Trying to authenticate using personal access tokens. @@ -74,7 +68,7 @@ def sample_connect_to_server(args): tableau_auth = TSC.PersonalAccessTokenAuth( token_name=args.token_name, personal_access_token=token, site_id=args.site ) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") + print("\nSigning in...\nServer: {}\nSite: {}\nToken name: {}".format(args.server, args.site, args.token_name)) if not tableau_auth: raise TabError("Did not create authentication object. Check arguments.") @@ -85,7 +79,10 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "3.19" + server.version = "2.6" + new_site: TSC.SiteItem = TSC.SiteItem("cdnear", content_url=env.site) + server.auth.switch_site(new_site) + print("Logged in successfully") return server diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..47af1f2f9 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -59,7 +59,7 @@ def main(): # Step 3: Download workbook to a temp directory if len(all_workbooks) == 0: - print(f"No workbook named {args.workbook_name} found.") + print("No workbook named {} found.".format(args.workbook_name)) else: tmpdir = tempfile.mkdtemp() try: @@ -68,10 +68,10 @@ def main(): # Step 4: Check if destination site exists, then sign in to the site all_sites, pagination_info = source_server.sites.get() found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + (True for site in all_sites if args.destination_site.lower() == site.content_url.lower()) ) if not found_destination_site: - error = f"No site named {args.destination_site} found." + error = "No site named {} found.".format(args.destination_site) raise LookupError(error) tableau_auth.site_id = args.destination_site @@ -85,7 +85,7 @@ def main(): new_workbook = dest_server.workbooks.publish( new_workbook, workbook_path, mode=TSC.Server.PublishMode.Overwrite ) - print(f"Successfully moved {new_workbook.name} ({new_workbook.id})") + print("Successfully moved {0} ({1})".format(new_workbook.name, new_workbook.id)) # Step 6: Delete workbook from source site and delete temp directory source_server.workbooks.delete(all_workbooks[0].id) diff --git a/samples/pagination_sample.py b/samples/pagination_sample.py index a68eed4b3..a7ae6dc89 100644 --- a/samples/pagination_sample.py +++ b/samples/pagination_sample.py @@ -57,7 +57,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Total: {count}\n") + print("Total: {}\n".format(count)) count = 0 page_options = TSC.RequestOptions(2, 3) @@ -65,7 +65,7 @@ def main(): for wb in TSC.Pager(server.workbooks, page_options): print(wb.name) count = count + 1 - print(f"Truncated Total: {count}\n") + print("Truncated Total: {}\n".format(count)) print("Your id: ", you.name, you.id, "\n") count = 0 @@ -76,7 +76,7 @@ def main(): for wb in TSC.Pager(server.workbooks, filtered_page_options): print(wb.name, " -- ", wb.owner_id) count = count + 1 - print(f"Filtered Total: {count}\n") + print("Filtered Total: {}\n".format(count)) # 2. QuerySet offers a fluent interface on top of the RequestOptions object print("Fetching workbooks again - this time filtered with QuerySet") @@ -90,7 +90,7 @@ def main(): count = count + 1 more = queryset.total_available > count page = page + 1 - print(f"QuerySet Total: {count}") + print("QuerySet Total: {}".format(count)) # 3. QuerySet also allows you to iterate over all objects without explicitly paging. print("Fetching again - this time without manually paging") diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..5ac768674 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -21,15 +21,10 @@ import argparse import logging -import os import tableauserverclient as TSC -import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +import env +import tableauserverclient.datetime_helpers def main(): @@ -57,13 +52,13 @@ def main(): args = parser.parse_args() if not args.server: - args.server = get_env("SERVER") + args.server = env.server if not args.site: - args.site = get_env("SITE") + args.site = env.site if not args.token_name: - args.token_name = get_env("TOKEN_NAME") + args.token_name = env.token_name if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") + args.token_value = env.token_value args.logging = "debug" args.file = "C:/dev/tab-samples/5M.tdsx" args.async_ = True @@ -116,17 +111,15 @@ def main(): new_job = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds, as_job=True ) - print(f"Datasource published asynchronously. Job ID: {new_job.id}") + print("Datasource published asynchronously. Job ID: {0}".format(new_job.id)) else: # Normal publishing, returns a datasource_item new_datasource = server.datasources.publish( new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - ( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() - ) + "{0}Datasource published. Datasource ID: {1}".format( + new_datasource.id, tableauserverclient.datetime_helpers.timestamp() ) ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..8a9f45279 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -80,7 +80,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. JOB ID: {new_job.id}") + print("Workbook published. JOB ID: {0}".format(new_job.id)) else: new_workbook = server.workbooks.publish( new_workbook, @@ -90,7 +90,7 @@ def main(): as_job=args.as_job, skip_connection_check=args.skip_connection_check, ) - print(f"Workbook published. ID: {new_workbook.id}") + print("Workbook published. ID: {0}".format(new_workbook.id)) else: error = "The default project could not be found." raise LookupError(error) diff --git a/samples/query_permissions.py b/samples/query_permissions.py index 3309acd90..4e509cd97 100644 --- a/samples/query_permissions.py +++ b/samples/query_permissions.py @@ -57,15 +57,17 @@ def main(): permissions = resource.permissions # Print result - print(f"\n{len(permissions)} permission rule(s) found for {args.resource_type} {args.resource_id}.") + print( + "\n{0} permission rule(s) found for {1} {2}.".format(len(permissions), args.resource_type, args.resource_id) + ) for permission in permissions: grantee = permission.grantee capabilities = permission.capabilities - print(f"\nCapabilities for {grantee.tag_name} {grantee.id}:") + print("\nCapabilities for {0} {1}:".format(grantee.tag_name, grantee.id)) for capability in capabilities: - print(f"\t{capability} - {capabilities[capability]}") + print("\t{0} - {1}".format(capability, capabilities[capability])) if __name__ == "__main__": diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..03daedf16 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -19,12 +19,12 @@ def handle_run(server, args): def handle_list(server, _): tasks, pagination = server.tasks.get() for task in tasks: - print(f"{task}") + print("{}".format(task)) def handle_info(server, args): task = server.tasks.get_by_id(args.id) - print(f"{task}") + print("{}".format(task)) def main(): diff --git a/samples/set_refresh_schedule.py b/samples/set_refresh_schedule.py index 153bb0ee5..56fd12e62 100644 --- a/samples/set_refresh_schedule.py +++ b/samples/set_refresh_schedule.py @@ -38,7 +38,7 @@ def usage(args): def make_filter(**kwargs): options = TSC.RequestOptions() - for item, value in list(kwargs.items()): + for item, value in kwargs.items(): name = getattr(TSC.RequestOptions.Field, item) options.filter.add(TSC.Filter(name, TSC.RequestOptions.Operator.Equals, value)) return options diff --git a/samples/update_connection.py b/samples/update_connection.py index 0fe2f342c..4af6592bc 100644 --- a/samples/update_connection.py +++ b/samples/update_connection.py @@ -45,7 +45,7 @@ def main(): update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) - connections = list([x for x in resource.connections if x.id == args.connection_id]) + connections = list(filter(lambda x: x.id == args.connection_id, resource.connections)) assert len(connections) == 1 connection = connections[0] connection.username = args.datasource_username diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py new file mode 100644 index 000000000..75f12262f --- /dev/null +++ b/samples/update_workbook_data_acceleration.py @@ -0,0 +1,109 @@ +#### +# This script demonstrates how to update workbook data acceleration using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook to try data acceleration. + # Note that data acceleration has a couple of requirements, please check the Tableau help page + # to verify your workbook/view is eligible for data acceleration. + + # Assuming 1st workbook is eligible for sample purposes + sample_workbook = all_workbooks[2] + + # Enable acceleration for all the views in the workbook + enable_config = dict() + enable_config["acceleration_enabled"] = True + enable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = enable_config + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + # Since we did not set any specific view, we will enable all views in the workbook + print("Enable acceleration for all the views in the workbook " + updated.name + ".") + + # Disable acceleration on one of the view in the workbook + # You have to populate_views first, then set the views of the workbook + # to the ones you want to update. + server.workbooks.populate_views(sample_workbook) + view_to_disable = sample_workbook.views[0] + sample_workbook.views = [view_to_disable] + + disable_config = dict() + disable_config["acceleration_enabled"] = False + disable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = disable_config + # To get the acceleration status on the response, set includeViewAccelerationStatus=true + # Note that you have to populate_views first to get the acceleration status, since + # acceleration status is per view basis (not per workbook) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) + view1 = updated.views[0] + print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") + + # Get acceleration status of the views in workbook using workbooks.get_by_id + # This won't need to do populate_views beforehand + my_workbook = server.workbooks.get_by_id(sample_workbook.id) + view1 = my_workbook.views[0] + view2 = my_workbook.views[1] + print( + "Fetching acceleration status for views in the workbook " + + updated.name + + ".\n" + + 'View "' + + view1.name + + '" has acceleration_status = ' + + view1.data_acceleration_config["acceleration_status"] + + ".\n" + + 'View "' + + view2.name + + '" has acceleration_status = ' + + view2.data_acceleration_config["acceleration_status"] + + "." + ) + + +if __name__ == "__main__": + main() diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..9e4d63dc1 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -45,7 +45,7 @@ def main(): with server.auth.sign_in(tableau_auth): # Get workbook all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..bab2cf05f 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -32,13 +32,11 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, - Resource, RevisionItem, ScheduleItem, SiteItem, ServerInfoItem, SubscriptionItem, - TableauItem, TableItem, TableauAuth, Target, @@ -58,7 +56,6 @@ PDFRequestOptions, RequestOptions, MissingRequiredFieldError, - FailedSignInError, NotSignedInError, ServerResponseError, Filter, @@ -68,68 +65,65 @@ ) __all__ = [ + "get_versions", + "DEFAULT_NAMESPACE", "BackgroundJobItem", "BackgroundJobItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", - "CSVRequestOptions", "CustomViewItem", + "DQWItem", "DailyInterval", "DataAlertItem", "DatabaseItem", "DataFreshnessPolicyItem", "DatasourceItem", - "DEFAULT_NAMESPACE", - "DQWItem", - "ExcelRequestOptions", - "FailedSignInError", "FavoriteItem", - "FileuploadItem", - "Filter", "FlowItem", "FlowRunItem", - "get_versions", + "FileuploadItem", "GroupItem", "GroupSetItem", "HourlyInterval", - "ImageRequestOptions", "IntervalItem", "JobItem", "JWTAuth", - "LinkedTaskFlowRunItem", - "LinkedTaskItem", - "LinkedTaskStepItem", "MetricItem", - "MissingRequiredFieldError", "MonthlyInterval", - "NotSignedInError", - "Pager", "PaginationItem", - "PDFRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", - "RequestOptions", - "Resource", "RevisionItem", "ScheduleItem", - "Server", - "ServerInfoItem", - "ServerResponseError", "SiteItem", - "Sort", + "ServerInfoItem", "SubscriptionItem", - "TableauAuth", - "TableauItem", "TableItem", + "TableauAuth", "Target", "TaskItem", "UserItem", "ViewItem", - "VirtualConnectionItem", "WebhookItem", "WeeklyInterval", "WorkbookItem", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "MissingRequiredFieldError", + "NotSignedInError", + "ServerResponseError", + "Filter", + "Pager", + "Server", + "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", + "VirtualConnectionItem", ] diff --git a/tableauserverclient/_version.py b/tableauserverclient/_version.py index 79dbed1d8..d47374097 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/_version.py @@ -84,7 +84,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= stderr=(subprocess.PIPE if hide_stderr else None), ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -94,7 +94,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -131,7 +131,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -144,7 +144,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -159,7 +159,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -183,11 +183,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +196,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -299,7 +299,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( full_tag, tag_prefix, ) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index a75112754..63872398f 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -6,13 +6,11 @@ DELAY_SLEEP_SECONDS = 0.1 +# The maximum size of a file that can be published in a single request is 64MB +FILESIZE_LIMIT_MB = 64 -class Config: - # The maximum size of a file that can be published in a single request is 64MB - @property - def FILESIZE_LIMIT_MB(self): - return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) +class Config: # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 3a7416e28..df936e315 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -3,7 +3,7 @@ from .property_decorators import property_not_empty -class ColumnItem: +class ColumnItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..d61bbb751 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_boolean -class ConnectionCredentials: +class ConnectionCredentials(object): """Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..62ff530c9 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -class ConnectionItem: +class ConnectionItem(object): def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None @@ -48,7 +48,7 @@ def query_tagging(self, value: Optional[bool]): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( - f"Cannot update value: Query tagging is always enabled for {self._connection_type} connections" + "Cannot update value: Query tagging is always enabled for {} connections".format(self._connection_type) ) return self._query_tagging = value @@ -59,7 +59,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp, ns) -> list["ConnectionItem"]: + def from_response(cls, resp, ns) -> List["ConnectionItem"]: all_connection_items = list() parsed_response = fromstring(resp) all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) @@ -82,7 +82,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: return all_connection_items @classmethod - def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: + def from_xml_element(cls, parsed_response, ns) -> List["ConnectionItem"]: """ @@ -93,7 +93,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: """ - all_connection_items: list["ConnectionItem"] = list() + all_connection_items: List["ConnectionItem"] = list() all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,8 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, List, Optional from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -12,14 +11,12 @@ from ..datetime_helpers import parse_datetime -class CustomViewItem: +class CustomViewItem(object): def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = 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 @@ -38,17 +35,11 @@ def __repr__(self: "CustomViewItem"): owner_info = "" if self._owner: owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown") - return f"" + return "".format(self.id, self.name, view_info, wb_info, owner_info) 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 @@ -64,24 +55,10 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "Custom View item must be populated with its png image first." + error = "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 @@ -127,7 +104,7 @@ def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: return item[0] @classmethod - def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: + def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) """ @@ -144,7 +121,7 @@ def list_from_response(cls, resp, ns, workbook_id="") -> list["CustomViewItem"]: """ @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns) for custom_view_xml in all_view_xml: diff --git a/tableauserverclient/models/data_acceleration_report_item.py b/tableauserverclient/models/data_acceleration_report_item.py index 3a8883bed..7424e6b95 100644 --- a/tableauserverclient/models/data_acceleration_report_item.py +++ b/tableauserverclient/models/data_acceleration_report_item.py @@ -1,8 +1,8 @@ from defusedxml.ElementTree import fromstring -class DataAccelerationReportItem: - class ComparisonRecord: +class DataAccelerationReportItem(object): + class ComparisonRecord(object): def __init__( self, site, diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 7285ee609..65be233e3 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ ) -class DataAlertItem: +class DataAlertItem(object): class Frequency: Once = "Once" Frequently = "Frequently" @@ -34,7 +34,7 @@ def __init__(self): self._workbook_name: Optional[str] = None self._project_id: Optional[str] = None self._project_name: Optional[str] = None - self._recipients: Optional[list[str]] = None + self._recipients: Optional[List[str]] = None def __repr__(self) -> str: return " Optional[str]: return self._creatorId @property - def recipients(self) -> list[str]: + def recipients(self) -> List[str]: return self._recipients or list() @property @@ -174,7 +174,7 @@ def _set_values( self._recipients = recipients @classmethod - def from_response(cls, resp, ns) -> list["DataAlertItem"]: + def from_response(cls, resp, ns) -> List["DataAlertItem"]: all_alert_items = list() parsed_response = fromstring(resp) all_alert_xml = parsed_response.findall(".//t:dataAlert", namespaces=ns) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index 6e0cb9001..f567c501c 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, Union, List from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +50,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[list[str]] = interval_item + self.interval_item: Optional[List[str]] = interval_item def __repr__(self): return ( @@ -62,11 +62,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[list[str]]: + def interval_item(self) -> Optional[List[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: list[str]): + def interval_item(self, value: List[str]): self._interval_item = value @property @@ -186,7 +186,7 @@ def parse_week_intervals(interval_values): def parse_month_intervals(interval_values): - error = f"Invalid interval value for a monthly frequency: {interval_values}." + error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] # First check if the list only have LastDay value. When using LastDay, there shouldn't be diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 4d4604461..dfc58e1bb 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -10,7 +10,7 @@ ) -class DatabaseItem: +class DatabaseItem(object): class ContentPermissions: LockedToProject = "LockedToDatabase" ManagedByOwner = "ManagedByOwner" @@ -45,7 +45,7 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet def __str__(self): - return f"" + return "".format(self._id, self.name) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -250,7 +250,7 @@ def _set_tables(self, tables): self._tables = tables def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional, Set, Tuple from defusedxml.ElementTree import fromstring @@ -18,14 +18,14 @@ from tableauserverclient.models.tag_item import TagItem -class DatasourceItem: +class DatasourceItem(object): class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" SiteDefault = "SiteDefault" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.description or "No Description", @@ -44,7 +44,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._encrypt_extracts = None self._has_extracts = None self._id: Optional[str] = None - self._initial_tags: set = set() + self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None @@ -55,7 +55,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.name = name self.owner_id: Optional[str] = None self.project_id = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self._permissions = None self._data_quality_warnings = None @@ -72,14 +72,14 @@ def ask_data_enablement(self, value: Optional[AskDataEnablement]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self) -> Optional[List[ConnectionItem]]: if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> Optional[list[PermissionsRule]]: + def permissions(self) -> Optional[List[PermissionsRule]]: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -177,7 +177,7 @@ def webpage_url(self) -> Optional[str]: return self._webpage_url @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Datasource item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -309,7 +309,7 @@ def _set_values( self._size = int(size) @classmethod - def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: + def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) @@ -326,7 +326,7 @@ def from_xml(cls, datasource_xml, ns): return datasource_item @staticmethod - def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: + def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: id_ = datasource_xml.get("id", None) name = datasource_xml.get("name", None) datasource_type = datasource_xml.get("type", None) diff --git a/tableauserverclient/models/dqw_item.py b/tableauserverclient/models/dqw_item.py index fbda9d9f2..ada041481 100644 --- a/tableauserverclient/models/dqw_item.py +++ b/tableauserverclient/models/dqw_item.py @@ -3,7 +3,7 @@ from tableauserverclient.datetime_helpers import parse_datetime -class DQWItem: +class DQWItem(object): class WarningType: WARNING = "WARNING" DEPRECATED = "DEPRECATED" diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 4fea280f7..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,27 +1,28 @@ import logging -from typing import Union from defusedxml.ElementTree import fromstring - from tableauserverclient.models.tableau_types import TableauItem + from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem +from typing import Dict, List from tableauserverclient.helpers.logging import logger +from typing import Dict, List, Union -FavoriteType = dict[ +FavoriteType = Dict[ str, - list[TableauItem], + List[TableauItem], ] class FavoriteItem: @classmethod - def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: + def from_response(cls, xml: str, namespace: Dict) -> FavoriteType: favorites: FavoriteType = { "datasources": [], "flows": [], diff --git a/tableauserverclient/models/fileupload_item.py b/tableauserverclient/models/fileupload_item.py index aea4dfe1f..e9bdd25b2 100644 --- a/tableauserverclient/models/fileupload_item.py +++ b/tableauserverclient/models/fileupload_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class FileuploadItem: +class FileuploadItem(object): def __init__(self): self._file_size = None self._upload_session_id = None diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..edce2ec97 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Set from defusedxml.ElementTree import fromstring @@ -14,9 +14,9 @@ from tableauserverclient.models.tag_item import TagItem -class FlowItem: +class FlowItem(object): def __repr__(self): - return " None: self._webpage_url: Optional[str] = None self._created_at: Optional[datetime.datetime] = None self._id: Optional[str] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._project_name: Optional[str] = None self._updated_at: Optional[datetime.datetime] = None self.name: Optional[str] = name self.owner_id: Optional[str] = None self.project_id: str = project_id - self.tags: set[str] = set() + self.tags: Set[str] = set() self.description: Optional[str] = None self._connections: Optional[ConnectionItem] = None @@ -170,7 +170,7 @@ def _set_values( self.owner_id = owner_id @classmethod - def from_response(cls, resp, ns) -> list["FlowItem"]: + def from_response(cls, resp, ns) -> List["FlowItem"]: all_flow_items = list() parsed_response = fromstring(resp) all_flow_xml = parsed_response.findall(".//t:flow", namespaces=ns) diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f2f1d561f..12281f4f8 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,13 +1,13 @@ import itertools from datetime import datetime -from typing import Optional +from typing import Dict, List, Optional, Type from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class FlowRunItem: +class FlowRunItem(object): def __init__(self) -> None: self._id: str = "" self._flow_id: Optional[str] = None @@ -71,7 +71,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: + def from_response(cls: Type["FlowRunItem"], resp: bytes, ns: Optional[Dict]) -> List["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..6c8f7eb01 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, TYPE_CHECKING +from typing import Callable, List, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -11,7 +11,7 @@ from tableauserverclient.server import Pager -class GroupItem: +class GroupItem(object): tag_name: str = "group" class LicenseMode: @@ -27,7 +27,7 @@ def __init__(self, name=None, domain_name=None) -> None: self.domain_name: Optional[str] = domain_name def __repr__(self): - return f"{self.__class__.__name__}({self.__dict__!r})" + return "{}({!r})".format(self.__class__.__name__, self.__dict__) @property def domain_name(self) -> Optional[str]: @@ -79,7 +79,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @classmethod - def from_response(cls, resp, ns) -> list["GroupItem"]: + def from_response(cls, resp, ns) -> List["GroupItem"]: all_group_items = list() parsed_response = fromstring(resp) all_group_xml = parsed_response.findall(".//t:group", namespaces=ns) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index aa653a79e..ffb57adf5 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, List, Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ class GroupSetItem: def __init__(self, name: Optional[str] = None) -> None: self.name = name self.id: Optional[str] = None - self.groups: list["GroupItem"] = [] + self.groups: List["GroupItem"] = [] self.group_count: int = 0 def __str__(self) -> str: @@ -25,13 +25,13 @@ def __repr__(self) -> str: return self.__str__() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: parsed_response = fromstring(response) all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) return [cls.from_xml(xml, ns) for xml in all_groupset_xml] @classmethod - def from_xml(cls, groupset_xml: ET.Element, ns: dict[str, str]) -> "GroupSetItem": + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": def get_group(group_xml: ET.Element) -> GroupItem: group_item = GroupItem() group_item._id = group_xml.get("id") diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..444674e19 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -1,7 +1,7 @@ from .property_decorators import property_is_valid_time, property_not_nullable -class IntervalItem: +class IntervalItem(object): class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -25,7 +25,7 @@ class Day: LastDay = "LastDay" -class HourlyInterval: +class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -73,12 +73,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -108,7 +108,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class DailyInterval: +class DailyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -141,12 +141,12 @@ def interval(self, intervals): for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): - error = f"Invalid weekDay interval {interval}" + error = "Invalid weekDay interval {}".format(interval) raise ValueError(error) # if an hourly interval is a number, it is an hours or minutes interval if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: - error = f"Invalid interval {interval} not in {str(VALID_INTERVALS)}" + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) raise ValueError(error) self._interval = intervals @@ -176,7 +176,7 @@ def _interval_type_pairs(self): return interval_type_pairs -class WeeklyInterval: +class WeeklyInterval(object): def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -213,7 +213,7 @@ def _interval_type_pairs(self): return [(IntervalItem.Occurrence.WeekDay, day) for day in self.interval] -class MonthlyInterval: +class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..155ce668b 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -7,7 +7,7 @@ from tableauserverclient.models.flow_run_item import FlowRunItem -class JobItem: +class JobItem(object): class FinishCode: """ Status codes as documented on @@ -27,7 +27,7 @@ def __init__( started_at: Optional[datetime.datetime] = None, completed_at: Optional[datetime.datetime] = None, finish_code: int = 0, - notes: Optional[list[str]] = None, + notes: Optional[List[str]] = None, mode: Optional[str] = None, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None, @@ -43,7 +43,7 @@ def __init__( self._started_at = started_at self._completed_at = completed_at self._finish_code = finish_code - self._notes: list[str] = notes or [] + self._notes: List[str] = notes or [] self._mode = mode self._workbook_id = workbook_id self._datasource_id = datasource_id @@ -81,7 +81,7 @@ def finish_code(self) -> int: return self._finish_code @property - def notes(self) -> list[str]: + def notes(self) -> List[str]: return self._notes @property @@ -139,7 +139,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @classmethod - def from_response(cls, xml, ns) -> list["JobItem"]: + def from_response(cls, xml, ns) -> List["JobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:job", namespaces=ns) @@ -191,7 +191,7 @@ def _parse_element(cls, element, ns): ) -class BackgroundJobItem: +class BackgroundJobItem(object): class Status: Pending: str = "Pending" InProgress: str = "InProgress" @@ -270,7 +270,7 @@ def priority(self) -> int: return self._priority @classmethod - def from_response(cls, xml, ns) -> list["BackgroundJobItem"]: + def from_response(cls, xml, ns) -> List["BackgroundJobItem"]: parsed_response = fromstring(xml) all_tasks_xml = parsed_response.findall(".//t:backgroundJob", namespaces=ns) return [cls._parse_element(x, ns) for x in all_tasks_xml] diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 14a0e4978..ae9b60425 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -14,7 +14,7 @@ def __init__(self) -> None: self.schedule: Optional[ScheduleItem] = None @classmethod - def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: parsed_response = fromstring(resp) return [ cls._parse_element(x, namespace) @@ -35,10 +35,10 @@ def __init__(self) -> None: self.id: Optional[str] = None self.step_number: Optional[int] = None self.stop_downstream_on_failure: Optional[bool] = None - self.task_details: list[LinkedTaskFlowRunItem] = [] + self.task_details: List[LinkedTaskFlowRunItem] = [] @classmethod - def from_task_xml(cls, xml, namespace) -> list["LinkedTaskStepItem"]: + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] @classmethod @@ -61,7 +61,7 @@ def __init__(self) -> None: self.flow_name: Optional[str] = None @classmethod - def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: all_tasks = [] for flow_run in xml.findall(".//t:flowRun[@id]", namespace): task = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 432fd861a..d8ba8e825 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import Optional +from typing import List, Optional, Set from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -8,7 +8,7 @@ from .permissions_item import Permission -class MetricItem: +class MetricItem(object): def __init__(self, name: Optional[str] = None): self._id: Optional[str] = None self._name: Optional[str] = name @@ -21,8 +21,8 @@ def __init__(self, name: Optional[str] = None): self._project_name: Optional[str] = None self._owner_id: Optional[str] = None self._view_id: Optional[str] = None - self._initial_tags: set[str] = set() - self.tags: set[str] = set() + self._initial_tags: Set[str] = set() + self.tags: Set[str] = set() self._permissions: Optional[Permission] = None @property @@ -126,7 +126,7 @@ def from_response( cls, resp: bytes, ns, - ) -> list["MetricItem"]: + ) -> List["MetricItem"]: all_metric_items = list() parsed_response = ET.fromstring(resp) all_metric_xml = parsed_response.findall(".//t:metric", namespaces=ns) diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index f30519be5..8cebd1c86 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -1,7 +1,7 @@ from defusedxml.ElementTree import fromstring -class PaginationItem: +class PaginationItem(object): def __init__(self): self._page_number = None self._page_size = None diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bb3487279..26f4ee7e8 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Optional +from typing import Dict, List, Optional from defusedxml.ElementTree import fromstring @@ -36,25 +36,23 @@ class Capability: ShareView = "ShareView" ViewComments = "ViewComments" ViewUnderlyingData = "ViewUnderlyingData" - VizqlDataApiAccess = "VizqlDataApiAccess" WebAuthoring = "WebAuthoring" Write = "Write" RunExplainData = "RunExplainData" CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" - PulseMetricDefine = "PulseMetricDefine" def __repr__(self): return "" class PermissionsRule: - def __init__(self, grantee: ResourceReference, capabilities: dict[str, str]) -> None: + def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities def __repr__(self): - return f"" + return "".format(self.grantee, self.capabilities) def __eq__(self, other: object) -> bool: if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): @@ -68,7 +66,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( @@ -88,7 +86,7 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.capabilities == other.capabilities: return self - capabilities = {*self.capabilities.keys(), *other.capabilities.keys()} + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): @@ -102,14 +100,14 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": return PermissionsRule(self.grantee, new_capabilities) @classmethod - def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: + def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) rules = [] permissions_rules_list_xml = parsed_response.findall(".//t:granteeCapabilities", namespaces=ns) for grantee_capability_xml in permissions_rules_list_xml: - capability_dict: dict[str, str] = {} + capability_dict: Dict[str, str] = {} grantee = PermissionsRule._parse_grantee_element(grantee_capability_xml, ns) @@ -118,7 +116,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: mode = capability_xml.get("mode") if name is None or mode is None: - logger.error(f"Capability was not valid: {capability_xml}") + logger.error("Capability was not valid: {}".format(capability_xml)) raise UnpopulatedPropertyError() else: capability_dict[name] = mode @@ -129,7 +127,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict[str, str]]) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute @@ -148,6 +146,6 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict elif grantee_type == "groupSet": grantee = GroupSetItem.as_reference(grantee_id) else: - raise UnknownGranteeTypeError(f"No support for grantee type of {grantee_type}") + raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) return grantee diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,6 +1,6 @@ import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,16 +8,14 @@ from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty -class ProjectItem: - ERROR_MSG = "Project item must be populated with permissions first." - +class ProjectItem(object): class ContentPermissions: LockedToProject: str = "LockedToProject" ManagedByOwner: str = "ManagedByOwner" LockedToProjectWithoutNested: str = "LockedToProjectWithoutNested" def __repr__(self): - return "".format( + return "".format( self._id, self.name, self.parent_id or "None (Top level)", self.content_permissions or "Not Set" ) @@ -45,9 +43,6 @@ def __init__( self._default_lens_permissions = None self._default_datarole_permissions = None self._default_metric_permissions = None - self._default_virtualconnection_permissions = None - self._default_database_permissions = None - self._default_table_permissions = None @property def content_permissions(self): @@ -61,63 +56,52 @@ def content_permissions(self, value: Optional[str]) -> None: @property def permissions(self): if self._permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._permissions() @property def default_datasource_permissions(self): if self._default_datasource_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datasource_permissions() @property def default_workbook_permissions(self): if self._default_workbook_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_workbook_permissions() @property def default_flow_permissions(self): if self._default_flow_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_flow_permissions() @property def default_lens_permissions(self): if self._default_lens_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_lens_permissions() @property def default_datarole_permissions(self): if self._default_datarole_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_datarole_permissions() @property def default_metric_permissions(self): if self._default_metric_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) + error = "Project item must be populated with permissions first." + raise UnpopulatedPropertyError(error) return self._default_metric_permissions() - @property - def default_virtualconnection_permissions(self): - if self._default_virtualconnection_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_virtualconnection_permissions() - - @property - def default_database_permissions(self): - if self._default_database_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_database_permissions() - - @property - def default_table_permissions(self): - if self._default_table_permissions is None: - raise UnpopulatedPropertyError(self.ERROR_MSG) - return self._default_table_permissions() - @property def id(self) -> Optional[str]: return self._id @@ -174,7 +158,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = "_default_{content}_permissions".format(content=content_type) setattr( self, attr, @@ -182,7 +166,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp, ns) -> List["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5048b3498..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,8 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional -from collections.abc import Container +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -12,7 +11,7 @@ def property_type_decorator(func): @wraps(func) def wrapper(self, value): if value is not None and not hasattr(enum_type, value): - error = f"Invalid value: {value}. {func.__name__} must be of type {enum_type.__name__}." + error = "Invalid value: {0}. {1} must be of type {2}.".format(value, func.__name__, enum_type.__name__) raise ValueError(error) return func(self, value) @@ -25,7 +24,7 @@ def property_is_boolean(func): @wraps(func) def wrapper(self, value): if not isinstance(value, bool): - error = f"Boolean expected for {func.__name__} flag." + error = "Boolean expected for {0} flag.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -36,7 +35,7 @@ def property_not_nullable(func): @wraps(func) def wrapper(self, value): if value is None: - error = f"{func.__name__} must be defined." + error = "{0} must be defined.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -47,7 +46,7 @@ def property_not_empty(func): @wraps(func) def wrapper(self, value): if not value: - error = f"{func.__name__} must not be empty." + error = "{0} must not be empty.".format(func.__name__) raise ValueError(error) return func(self, value) @@ -67,7 +66,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -82,7 +81,7 @@ def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = def property_type_decorator(func): @wraps(func) def wrapper(self, value): - error = f"Invalid property defined: '{value}'. Integer value expected." + error = "Invalid property defined: '{}'. Integer value expected.".format(value) if range is None: if isinstance(value, int): @@ -134,7 +133,7 @@ def wrapper(self, value): return func(self, value) if not isinstance(value, str): raise ValueError( - f"Cannot convert {value.__class__.__name__} into a datetime, cannot update {func.__name__}" + "Cannot convert {} into a datetime, cannot update {}".format(value.__class__.__name__, func.__name__) ) dt = parse_datetime(value) @@ -147,11 +146,11 @@ def property_is_data_acceleration_config(func): @wraps(func) def wrapper(self, value): if not isinstance(value, dict): - raise ValueError(f"{value.__class__.__name__} is not type 'dict', cannot update {func.__name__})") + raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): - error = f"{func.__name__} should have 2 keys " + error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" - error += f"instead you have {value.keys()}" + error += "instead you have {}".format(value.keys()) raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 4c1fff564..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,10 +1,10 @@ -class ResourceReference: +class ResourceReference(object): def __init__(self, id_, tag_name): self.id = id_ self.tag_name = tag_name def __str__(self): - return f"" + return "".format(self._id, self._tag_name) __repr__ = __str__ diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 1b4cc6249..a0e6a1bd5 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,12 +1,12 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -class RevisionItem: +class RevisionItem(object): def __init__(self): self._resource_id: Optional[str] = None self._resource_name: Optional[str] = None @@ -56,7 +56,7 @@ def __repr__(self): ) @classmethod - def from_response(cls, resp: bytes, ns, resource_item) -> list["RevisionItem"]: + def from_response(cls, resp: bytes, ns, resource_item) -> List["RevisionItem"]: all_revision_items = list() parsed_response = fromstring(resp) all_revision_xml = parsed_response.findall(".//t:revision", namespaces=ns) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -19,7 +19,7 @@ Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] -class ScheduleItem: +class ScheduleItem(object): class Type: Extract = "Extract" Flow = "Flow" @@ -336,7 +336,7 @@ def parse_add_to_schedule_response(response, ns): all_task_xml = parsed_response.findall(".//t:task", namespaces=ns) error = ( - f"Status {response.status_code}: {response.reason}" + "Status {}: {}".format(response.status_code, response.reason) if response.status_code < 200 or response.status_code >= 300 else None ) diff --git a/tableauserverclient/models/server_info_item.py b/tableauserverclient/models/server_info_item.py index b13f26740..57fc51af9 100644 --- a/tableauserverclient/models/server_info_item.py +++ b/tableauserverclient/models/server_info_item.py @@ -6,29 +6,7 @@ from tableauserverclient.helpers.logging import logger -class ServerInfoItem: - """ - The ServerInfoItem class contains the build and version information for - Tableau Server. The server information is accessed with the - server_info.get() method, which returns an instance of the ServerInfo class. - - Attributes - ---------- - product_version : str - Shows the version of the Tableau Server or Tableau Cloud - (for example, 10.2.0). - - build_number : str - Shows the specific build number (for example, 10200.17.0329.1446). - - rest_api_version : str - Shows the supported REST API version number. Note that this might be - different from the default value specified for the server, with the - Server.version attribute. To take advantage of new features, you should - query the server and set the Server.version to match the supported REST - API version number. - """ - +class ServerInfoItem(object): def __init__(self, product_version, build_number, rest_api_version): self._product_version = product_version self._build_number = build_number @@ -62,11 +40,13 @@ def from_response(cls, resp, ns): try: parsed_response = fromstring(resp) except xml.etree.ElementTree.ParseError as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) return cls("Unknown", "Unknown", "Unknown") except Exception as error: - logger.exception(f"Unexpected response for ServerInfo: {resp}") - raise error + logger.info("Unexpected response for ServerInfo: {}".format(resp)) + logger.info(error) + return cls("Unknown", "Unknown", "Unknown") product_version_tag = parsed_response.find(".//t:productVersion", namespaces=ns) rest_api_version_tag = parsed_response.find(".//t:restApiVersion", namespaces=ns) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..b651e5773 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,79 +14,13 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import Optional, Union, TYPE_CHECKING +from typing import List, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server -class SiteItem: - """ - The SiteItem class contains the members or attributes for the site resources - on Tableau Server or Tableau Cloud. The SiteItem class defines the - information you can request or query from Tableau Server or Tableau Cloud. - The class members correspond to the attributes of a server request or - response payload. - - Attributes - ---------- - name: str - The name of the site. The name of the default site is "". - - content_url: str - The path to the site. - - admin_mode: str - (Optional) For Tableau Server only. Specify ContentAndUsers to allow - site administrators to use the server interface and tabcmd commands to - add and remove users. (Specifying this option does not give site - administrators permissions to manage users using the REST API.) Specify - ContentOnly to prevent site administrators from adding or removing - users. (Server administrators can always add or remove users.) - - user_quota: int - (Optional) Specifies the total number of users for the site. The number - can't exceed the number of licenses activated for the site; and if - tiered capacity attributes are set, then user_quota will equal the sum - of the tiered capacity values, and attempting to set user_quota will - cause an error. - - tier_explorer_capacity: int - tier_creator_capacity: int - tier_viewer_capacity: int - (Optional) The maximum number of licenses for users with the Creator, - Explorer, or Viewer role, respectively, allowed on a site. - - storage_quota: int - (Optional) Specifies the maximum amount of space for the new site, in - megabytes. If you set a quota and the site exceeds it, publishers will - be prevented from uploading new content until the site is under the - limit again. - - disable_subscriptions: bool - (Optional) Specify true to prevent users from being able to subscribe - to workbooks on the specified site. The default is False. - - subscribe_others_enabled: bool - (Optional) Specify false to prevent server administrators, site - administrators, and project or content owners from being able to - subscribe other users to workbooks on the specified site. The default - is True. - - revision_history_enabled: bool - (Optional) Specify true to enable revision history for content resources - (workbooks and datasources). The default is False. - - revision_limit: int - (Optional) Specifies the number of revisions of a content source - (workbook or data source) to allow. On Tableau Server, the default is - 25. - - state: str - Shows the current state of the site (Active or Suspended). - - """ - +class SiteItem(object): _user_quota: Optional[int] = None _tier_creator_capacity: Optional[int] = None _tier_explorer_capacity: Optional[int] = None @@ -939,7 +873,7 @@ def _set_values( self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window @classmethod - def from_response(cls, resp, ns) -> list["SiteItem"]: + def from_response(cls, resp, ns) -> List["SiteItem"]: all_site_items = list() parsed_response = fromstring(resp) all_site_xml = parsed_response.findall(".//t:site", namespaces=ns) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 61c75e2d6..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import List, Type, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -10,7 +10,7 @@ from .target import Target -class SubscriptionItem: +class SubscriptionItem(object): def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -79,7 +79,7 @@ def suspended(self, value: bool) -> None: self._suspended = value @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + def from_response(cls: Type, xml: bytes, ns) -> List["SubscriptionItem"]: parsed_response = fromstring(xml) all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..f9df8a8f3 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -4,7 +4,7 @@ from .property_decorators import property_not_empty, property_is_boolean -class TableItem: +class TableItem(object): def __init__(self, name, description=None): self._id = None self.description = description diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7d7981433..10cf58723 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Optional +from typing import Dict, Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: credentials = ( "Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request" @@ -32,43 +32,6 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - """ - The TableauAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - user name, password, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - username : str - The user name for the sign-in request. - - password : str - The password for the sign-in request. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD', site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - def __init__( self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None ) -> None: @@ -79,7 +42,7 @@ def __init__( self.username = username @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -92,43 +55,6 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - """ - The PersonalAccessTokenAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - token name, token secret, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - token_name : str - The name of the personal access token. - - personal_access_token : str - The personal access token secret for the sign in request. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> tableau_auth = TSC.PersonalAccessTokenAuth("token_name", "token_secret", site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - def __init__( self, token_name: str, @@ -143,7 +69,7 @@ def __init__( self.personal_access_token = personal_access_token @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -162,42 +88,6 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - """ - The JWTAuth class defines the information you can set in a sign-in - request. The class members correspond to the attributes of a server request - or response payload. To use this class, create a new instance, supplying - an encoded JSON Web Token, and site information if necessary, and pass the - request object to the Auth.sign_in method. - - Parameters - ---------- - token : str - The encoded JSON Web Token. - - site_id : str, optional - This corresponds to the contentUrl attribute in the Tableau REST API. - The site_id is the portion of the URL that follows the /site/ in the - URL. For example, "MarketingTeam" is the site_id in the following URL - MyServer/#/site/MarketingTeam/projects. To specify the default site on - Tableau Server, you can use an empty string '' (single quotes, no - space). For Tableau Cloud, you must provide a value for the site_id. - - user_id_to_impersonate : str, optional - Specifies the id (not the name) of the user to sign in as. This is not - available for Tableau Online. - - Examples - -------- - >>> import jwt - >>> import tableauserverclient as TSC - - >>> jwt_token = jwt.encode(...) - >>> tableau_auth = TSC.JWTAuth(token, site_id='CONTENTURL') - >>> server = TSC.Server('https://SERVER_URL', use_server_version=True) - >>> server.auth.sign_in(tableau_auth) - - """ - def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") @@ -205,7 +95,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"jwt": self.jwt} def __repr__(self): diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..bac072076 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -28,8 +28,8 @@ class Resource: TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] -def plural_type(content_type: Union[Resource, str]) -> str: +def plural_type(content_type: Resource) -> str: if content_type == Resource.Lens: return "lenses" else: - return f"{content_type}s" + return "{}s".format(content_type) diff --git a/tableauserverclient/models/tag_item.py b/tableauserverclient/models/tag_item.py index cde755f05..afa0a0762 100644 --- a/tableauserverclient/models/tag_item.py +++ b/tableauserverclient/models/tag_item.py @@ -1,15 +1,16 @@ import xml.etree.ElementTree as ET +from typing import Set from defusedxml.ElementTree import fromstring -class TagItem: +class TagItem(object): @classmethod - def from_response(cls, resp: bytes, ns) -> set[str]: + def from_response(cls, resp: bytes, ns) -> Set[str]: return cls.from_xml_element(fromstring(resp), ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns) -> set[str]: + def from_xml_element(cls, parsed_response: ET.Element, ns) -> Set[str]: all_tags = set() tag_elem = parsed_response.findall(".//t:tag", namespaces=ns) for tag_xml in tag_elem: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from defusedxml.ElementTree import fromstring @@ -8,7 +8,7 @@ from tableauserverclient.models.target import Target -class TaskItem: +class TaskItem(object): class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" @@ -48,9 +48,9 @@ def __repr__(self) -> str: ) @classmethod - def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> list["TaskItem"]: + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]: parsed_response = fromstring(xml) - all_tasks_xml = parsed_response.findall(f".//t:task/t:{task_type}", namespaces=ns) + all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..fe659575a 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Optional, TYPE_CHECKING +from typing import Dict, List, Optional, TYPE_CHECKING, Tuple from defusedxml.ElementTree import fromstring @@ -18,35 +18,10 @@ from tableauserverclient.server import Pager -class UserItem: - """ - The UserItem class contains the members or attributes for the view - resources on Tableau Server. The UserItem class defines the information you - can request or query from Tableau Server. The class attributes correspond - to the attributes of a server request or response payload. - - - Parameters - ---------- - name: str - The name of the user. - - site_role: str - The role of the user on the site. - - auth_setting: str - Required attribute for Tableau Cloud. How the user autenticates to the - server. - """ - +class UserItem(object): tag_name: str = "user" class Roles: - """ - The Roles class contains the possible roles for a user on Tableau - Server. - """ - Interactor = "Interactor" Publisher = "Publisher" ServerAdministrator = "ServerAdministrator" @@ -68,11 +43,6 @@ class Roles: SupportUser = "SupportUser" class Auth: - """ - The Auth class contains the possible authentication settings for a user - on Tableau Cloud. - """ - OpenID = "OpenID" SAML = "SAML" TableauIDWithMFA = "TableauIDWithMFA" @@ -87,7 +57,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[dict[str, list]] = None + self._favorites: Optional[Dict[str, List]] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -99,7 +69,7 @@ def __init__( def __str__(self) -> str: str_site_role = self.site_role or "None" - return f"" + return "".format(self.id, self.name, str_site_role) def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @@ -171,7 +141,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> dict[str, list]: + def favorites(self) -> Dict[str, List]: if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) @@ -240,12 +210,12 @@ def _set_values( self._domain_name = domain_name @classmethod - def from_response(cls, resp, ns) -> list["UserItem"]: + def from_response(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:user" return cls._parse_xml(element_name, resp, ns) @classmethod - def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: + def from_response_as_owner(cls, resp, ns) -> List["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) @@ -313,7 +283,7 @@ def _parse_element(user_xml, ns): domain_name, ) - class CSVImport: + class CSVImport(object): """ This class includes hardcoded options and logic for the CSV file format defined for user import https://help.tableau.com/current/server/en-us/users_import.htm @@ -338,7 +308,7 @@ def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None line = line.strip().lower() - values: list[str] = list(map(str.strip, line.split(","))) + values: List[str] = list(map(str.strip, line.split(","))) user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) if len(values) > 1: if len(values) > UserItem.CSVImport.ColumnType.MAX: @@ -367,7 +337,7 @@ def create_user_from_line(line: str): # Read through an entire CSV file meant for user import # Return the number of valid lines and a list of all the invalid lines @staticmethod - def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]: + def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]: num_valid_lines = 0 invalid_lines = [] csv_file.seek(0) # set to start of file in case it has been read earlier @@ -375,11 +345,11 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l while line and line != "": try: # do not print passwords - logger.info(f"Reading user {line[:4]}") + logger.info("Reading user {}".format(line[:4])) UserItem.CSVImport._validate_import_line_or_throw(line, logger) num_valid_lines += 1 except Exception as exc: - logger.info(f"Error parsing {line[:4]}: {exc}") + logger.info("Error parsing {}: {}".format(line[:4], exc)) invalid_lines.append(line) line = csv_file.readline() return num_valid_lines, invalid_lines @@ -388,7 +358,7 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: - _valid_attributes: list[list[str]] = [ + _valid_attributes: List[List[str]] = [ [], [], [], @@ -403,23 +373,23 @@ def _validate_import_line_or_throw(incoming, logger) -> None: if len(line) > UserItem.CSVImport.ColumnType.MAX: raise AttributeError("Too many attributes in line") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] - logger.debug(f"> details - {username}") + logger.debug("> details - {}".format(username)) UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") + logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i])) UserItem.CSVImport._validate_attribute_value( line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) ) # Given a restricted set of possible values, confirm the item is in that set @staticmethod - def _validate_attribute_value(item: str, possible_values: list[str], column_type) -> None: + def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None: if item is None or item == "": # value can be empty for any column except user, which is checked elsewhere return if item in possible_values or possible_values == []: return - raise AttributeError(f"Invalid value {item} for {column_type}") + raise AttributeError("Invalid value {} for {}".format(item, column_type)) # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,8 +1,7 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional -from collections.abc import Iterator +from typing import Callable, Iterator, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -12,13 +11,13 @@ from .tag_item import TagItem -class ViewItem: +class ViewItem(object): def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None self._id: Optional[str] = None self._image: Optional[Callable[[], bytes]] = None - self._initial_tags: set[str] = set() + self._initial_tags: Set[str] = set() self._name: Optional[str] = None self._owner_id: Optional[str] = None self._preview_image: Optional[Callable[[], bytes]] = None @@ -30,15 +29,15 @@ def __init__(self) -> None: self._sheet_type: Optional[str] = None self._updated_at: Optional[datetime] = None self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None - self.tags: set[str] = set() + self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None + self.tags: Set[str] = set() self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -147,21 +146,21 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "View item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() - def _set_permissions(self, permissions: Callable[[], list[PermissionsRule]]) -> None: + def _set_permissions(self, permissions: Callable[[], List[PermissionsRule]]) -> None: self._permissions = permissions @classmethod - def from_response(cls, resp: "Response", ns, workbook_id="") -> list["ViewItem"]: + def from_response(cls, resp: "Response", ns, workbook_id="") -> List["ViewItem"]: return cls.from_xml_element(fromstring(resp), ns, workbook_id) @classmethod - def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["ViewItem"]: + def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["ViewItem"]: all_view_items = list() all_view_xml = parsed_response.findall(".//t:view", namespaces=ns) for view_xml in all_view_xml: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index e9e22be1e..76a3b5dea 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,7 +1,6 @@ import datetime as dt import json -from typing import Callable, Optional -from collections.abc import Iterable +from typing import Callable, Dict, Iterable, List, Optional from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -24,7 +23,7 @@ def __init__(self, name: str) -> None: self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None self.project_id: Optional[str] = None self.owner_id: Optional[str] = None - self.content: Optional[dict[str, dict]] = None + self.content: Optional[Dict[str, dict]] = None self.certification_note: Optional[str] = None def __str__(self) -> str: @@ -41,7 +40,7 @@ def id(self) -> Optional[str]: return self._id @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -54,12 +53,12 @@ def connections(self) -> Iterable[ConnectionItem]: return self._connections() @classmethod - def from_response(cls, response: bytes, ns: dict[str, str]) -> list["VirtualConnectionItem"]: + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: parsed_response = fromstring(response) return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] @classmethod - def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": v_conn = cls(xml.get("name", "")) v_conn._id = xml.get("id", None) v_conn.webpage_url = xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..e4d5e4aa0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,6 @@ import re import xml.etree.ElementTree as ET -from typing import Optional +from typing import List, Optional, Tuple, Type from defusedxml.ElementTree import fromstring @@ -13,7 +13,7 @@ def _parse_event(events): return NAMESPACE_RE.sub("", event.tag) -class WebhookItem: +class WebhookItem(object): def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None @@ -45,10 +45,10 @@ def event(self) -> Optional[str]: @event.setter def event(self, value: str) -> None: - self._event = f"webhook-source-event-{value}" + self._event = "webhook-source-event-{}".format(value) @classmethod - def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: + def from_response(cls: Type["WebhookItem"], resp: bytes, ns) -> List["WebhookItem"]: all_webhooks_items = list() parsed_response = fromstring(resp) all_webhooks_xml = parsed_response.findall(".//t:webhook", namespaces=ns) @@ -61,7 +61,7 @@ def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookIte return all_webhooks_items @staticmethod - def _parse_element(webhook_xml: ET.Element, ns) -> tuple: + def _parse_element(webhook_xml: ET.Element, ns) -> Tuple: id = webhook_xml.get("id", None) name = webhook_xml.get("name", None) @@ -82,4 +82,4 @@ def _parse_element(webhook_xml: ET.Element, ns) -> tuple: return id, name, url, event, owner_id def __repr__(self) -> str: - return f"" + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Dict, List, Optional, Set from defusedxml.ElementTree import fromstring @@ -20,85 +20,7 @@ from .data_freshness_policy_item import DataFreshnessPolicyItem -class WorkbookItem: - """ - The workbook resources for Tableau are defined in the WorkbookItem class. - The class corresponds to the workbook resources you can access using the - Tableau REST API. Some workbook methods take an instance of the WorkbookItem - class as arguments. The workbook item specifies the project. - - Parameters - ---------- - project_id : Optional[str], optional - The project ID for the workbook, by default None. - - name : Optional[str], optional - The name of the workbook, by default None. - - show_tabs : bool, optional - Determines whether the workbook shows tabs for the view. - - Attributes - ---------- - connections : list[ConnectionItem] - The list of data connections (ConnectionItem) for the data sources used - by the workbook. You must first call the workbooks.populate_connections - method to access this data. See the ConnectionItem class. - - content_url : Optional[str] - The name of the workbook as it appears in the URL. - - created_at : Optional[datetime.datetime] - The date and time the workbook was created. - - description : Optional[str] - User-defined description of the workbook. - - id : Optional[str] - The identifier for the workbook. You need this value to query a specific - workbook or to delete a workbook with the get_by_id and delete methods. - - owner_id : Optional[str] - The identifier for the owner (UserItem) of the workbook. - - preview_image : bytes - The thumbnail image for the view. You must first call the - workbooks.populate_preview_image method to access this data. - - project_name : Optional[str] - The name of the project that contains the workbook. - - size: int - The size of the workbook in megabytes. - - hidden_views: Optional[list[str]] - List of string names of views that need to be hidden when the workbook - is published. - - tags: set[str] - The set of tags associated with the workbook. - - updated_at : Optional[datetime.datetime] - The date and time the workbook was last updated. - - views : list[ViewItem] - The list of views (ViewItem) for the workbook. You must first call the - workbooks.populate_views method to access this data. See the ViewItem - class. - - web_page_url : Optional[str] - The full URL for the workbook. - - Examples - -------- - # creating a new instance of a WorkbookItem - >>> import tableauserverclient as TSC - - >>> # Create new workbook_item with project id '3a8b6148-493c-11e6-a621-6f3499394a39' - - >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') - """ - +class WorkbookItem(object): def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: self._connections = None self._content_url = None @@ -113,15 +35,15 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], list[ViewItem]]] = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None # workaround for Personal Space workbooks without a project self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[list[str]] = None - self.tags: set[str] = set() + self.hidden_views: Optional[List[str]] = None + self.tags: Set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -134,7 +56,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, return None def __str__(self): - return "".format( + return "".format( self._id, self.name, self.content_url, self.project_id ) @@ -142,14 +64,14 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def connections(self) -> list[ConnectionItem]: + def connections(self) -> List[ConnectionItem]: if self._connections is None: error = "Workbook item must be populated with connections first." raise UnpopulatedPropertyError(error) return self._connections() @property - def permissions(self) -> list[PermissionsRule]: + def permissions(self) -> List[PermissionsRule]: if self._permissions is None: error = "Workbook item must be populated with permissions first." raise UnpopulatedPropertyError(error) @@ -230,7 +152,7 @@ def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @property - def views(self) -> list[ViewItem]: + def views(self) -> List[ViewItem]: # Views can be set in an initial workbook response OR by a call # to Server. Without getting too fancy, I think we can rely on # returning a list from the response, until they call @@ -269,7 +191,7 @@ def data_freshness_policy(self, value): self._data_freshness_policy = value @property - def revisions(self) -> list[RevisionItem]: + def revisions(self) -> List[RevisionItem]: if self._revisions is None: error = "Workbook item must be populated with revisions first." raise UnpopulatedPropertyError(error) @@ -281,7 +203,7 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_views(self, views: Callable[[], list[ViewItem]]) -> None: + def _set_views(self, views: Callable[[], List[ViewItem]]) -> None: self._views = views def _set_pdf(self, pdf: Callable[[], bytes]) -> None: @@ -394,7 +316,7 @@ def _set_values( self.data_freshness_policy = data_freshness_policy @classmethod - def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: + def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: all_workbook_items = list() parsed_response = fromstring(resp) all_workbook_xml = parsed_response.findall(".//t:workbook", namespaces=ns) diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 54ac46d8d..d225ecff6 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -11,7 +11,7 @@ class UnknownNamespaceError(Exception): pass -class Namespace: +class Namespace(object): def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..f5cd1d236 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -11,7 +11,7 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError +from tableauserverclient.server.endpoint.exceptions import NotSignedInError from tableauserverclient.server.endpoint import ( Auth, @@ -57,7 +57,6 @@ "Sort", "Server", "Pager", - "FailedSignInError", "NotSignedInError", "Auth", "CustomViews", diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..468d469a7 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -16,7 +16,7 @@ class Auth(Endpoint): - class contextmgr: + class contextmgr(object): def __init__(self, callback): self._callback = callback @@ -28,7 +28,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/auth" + return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") def sign_in(self, auth_req: "Credentials") -> contextmgr: @@ -41,32 +41,8 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: optionally a user_id to impersonate. Creates a context manager that will sign out of the server upon exit. - - Parameters - ---------- - auth_req : Credentials - The credentials object to use for signing in. Can be a TableauAuth, - PersonalAccessTokenAuth, or JWTAuth object. - - Returns - ------- - contextmgr - A context manager that will sign out of the server upon exit. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create an auth object - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') - - >>> # create an instance for your server - >>> server = TSC.Server('https://SERVER_URL') - - >>> # call the sign-in method with the auth object - >>> server.auth.sign_in(tableau_auth) """ - url = f"{self.baseurl}/signin" + url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False @@ -87,25 +63,22 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. # The distinct methods are mostly useful for explicitly showing api version support for each auth type @api(version="3.6") def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: - """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="3.17") def sign_in_with_json_web_token(self, auth_req: "Credentials") -> contextmgr: - """Passthrough to sign_in method""" return self.sign_in(auth_req) @api(version="2.0") def sign_out(self) -> None: - """Sign out of current session.""" - url = f"{self.baseurl}/signout" + url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): return @@ -115,34 +88,7 @@ def sign_out(self) -> None: @api(version="2.6") def switch_site(self, site_item: "SiteItem") -> contextmgr: - """ - Switch to a different site on the server. This will sign out of the - current site and sign in to the new site. If used as a context manager, - will sign out of the new site upon exit. - - Parameters - ---------- - site_item : SiteItem - The site to switch to. - - Returns - ------- - contextmgr - A context manager that will sign out of the new site upon exit. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # Find the site you want to switch to - >>> new_site = server.sites.get_by_id("9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d") - >>> # switch to the new site - >>> with server.auth.switch_site(new_site): - >>> # do something on the new site - >>> pass - - """ - url = f"{self.baseurl}/switchSite" + url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: server_response = self.post_request(url, switch_req) @@ -158,14 +104,11 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token) - logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + logger.info("Signed into {0} as user with id {1}".format(self.parent_srv.server_address, user_id)) return Auth.contextmgr(self.sign_out) @api(version="3.10") def revoke_all_server_admin_tokens(self) -> None: - """ - Revokes all personal access tokens for all server admins on the server. - """ - url = f"{self.baseurl}/revokeAllServerAdminTokens" + url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index b02b05d78..57a5b0100 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,23 +1,15 @@ 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 typing import List, Optional, Tuple, Union -from tableauserverclient.config import BYTES_PER_MB, config +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB 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, - PDFRequestOptions, - CSVRequestOptions, -) +from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions from tableauserverclient.helpers.logging import logger @@ -41,11 +33,11 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(CustomViews, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/customviews" + return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def expurl(self) -> str: @@ -63,7 +55,7 @@ def expurl(self) -> str: """ @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]: logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,8 +68,8 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) - logger.info(f"Querying custom view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + logger.info("Querying custom view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) server_response = self.get_request(url) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,53 +83,17 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for custom view (ID: {view_item.id})") + logger.info("Populated image for custom view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image - @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) + """ + Not yet implemented: pdf or csv exports + """ @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: @@ -149,10 +105,10 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: return view_item # Update the custom view owner or name - url = f"{self.baseurl}/{view_item.id}" + url = "{0}/{1}".format(self.baseurl, view_item.id) update_req = RequestFactory.CustomView.update_req(view_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated custom view (ID: {view_item.id})") + logger.info("Updated custom view (ID: {0})".format(view_item.id)) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) # Delete 1 view by id @@ -161,9 +117,9 @@ def delete(self, view_id: str) -> None: if not view_id: error = "Custom View ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{view_id}" + url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) - logger.info(f"Deleted single custom view (ID: {view_id})") + logger.info("Deleted single custom view (ID: {0})".format(view_id)) @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @@ -188,7 +144,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust else: raise ValueError("File path or file object required for publishing custom view.") - if size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: upload_session_id = self.parent_srv.fileuploads.upload(file) url = f"{url}?uploadSessionId={upload_session_id}" xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) diff --git a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py index 579001156..256a6e766 100644 --- a/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py +++ b/tableauserverclient/server/endpoint/data_acceleration_report_endpoint.py @@ -10,14 +10,14 @@ class DataAccelerationReport(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(DataAccelerationReport, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAccelerationReport" + return "{0}/sites/{1}/dataAccelerationReport".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.8") def get(self, req_options=None): diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index ba3ecd74f..fd02d2e4a 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: @@ -17,14 +17,14 @@ class DataAlerts(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(DataAlerts, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" + return "{0}/sites/{1}/dataAlerts".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,8 +38,8 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: if not dataAlert_id: error = "dataAlert ID undefined." raise ValueError(error) - logger.info(f"Querying single dataAlert (ID: {dataAlert_id})") - url = f"{self.baseurl}/{dataAlert_id}" + logger.info("Querying single dataAlert (ID: {0})".format(dataAlert_id)) + url = "{0}/{1}".format(self.baseurl, dataAlert_id) server_response = self.get_request(url) return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -55,9 +55,9 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: error = "Dataalert ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_id) self.delete_request(url) - logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") + logger.info("Deleted single dataAlert (ID: {0})".format(dataAlert_id)) @api(version="3.2") def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: @@ -80,9 +80,9 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni error = "User ID undefined." raise ValueError(error) # DELETE /api/api-version/sites/site-id/dataAlerts/data-alert-id/users/user-id - url = f"{self.baseurl}/{dataAlert_id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, dataAlert_id, user_id) self.delete_request(url) - logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") + logger.info("Deleted User (ID {0}) from dataAlert (ID: {1})".format(user_id, dataAlert_id)) @api(version="3.2") def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: @@ -98,10 +98,10 @@ def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{dataAlert_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.add_user_to_alert(dataAlert_item, user_id) server_response = self.post_request(url, update_req) - logger.info(f"Added user (ID {user_id}) to dataAlert item (ID: {dataAlert_item.id})") + logger.info("Added user (ID {0}) to dataAlert item (ID: {1})".format(user_id, dataAlert_item.id)) added_user = UserItem.from_response(server_response.content, self.parent_srv.namespace)[0] return added_user @@ -111,9 +111,9 @@ def update(self, dataAlert_item: DataAlertItem) -> DataAlertItem: error = "Dataalert item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{dataAlert_item.id}" + url = "{0}/{1}".format(self.baseurl, dataAlert_item.id) update_req = RequestFactory.DataAlert.update_req(dataAlert_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated dataAlert item (ID: {dataAlert_item.id})") + logger.info("Updated dataAlert item (ID: {0})".format(dataAlert_item.id)) updated_dataAlert = DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_dataAlert diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..2f8fece07 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Union, Iterable, Set from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -16,7 +15,7 @@ class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Databases, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -24,7 +23,7 @@ def __init__(self, parent_srv): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" + return "{0}/sites/{1}/databases".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -41,8 +40,8 @@ def get_by_id(self, database_id): if not database_id: error = "database ID undefined." raise ValueError(error) - logger.info(f"Querying single database (ID: {database_id})") - url = f"{self.baseurl}/{database_id}" + logger.info("Querying single database (ID: {0})".format(database_id)) + url = "{0}/{1}".format(self.baseurl, database_id) server_response = self.get_request(url) return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -51,9 +50,9 @@ def delete(self, database_id): if not database_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{database_id}" + url = "{0}/{1}".format(self.baseurl, database_id) self.delete_request(url) - logger.info(f"Deleted single database (ID: {database_id})") + logger.info("Deleted single database (ID: {0})".format(database_id)) @api(version="3.5") def update(self, database_item): @@ -61,10 +60,10 @@ def update(self, database_item): error = "Database item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{database_item.id}" + url = "{0}/{1}".format(self.baseurl, database_item.id) update_req = RequestFactory.Database.update_req(database_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated database item (ID: {database_item.id})") + logger.info("Updated database item (ID: {0})".format(database_item.id)) updated_database = DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_database @@ -79,10 +78,10 @@ def column_fetcher(): return self._get_tables_for_database(database_item) database_item._set_tables(column_fetcher) - logger.info(f"Populated tables for database (ID: {database_item.id}") + logger.info("Populated tables for database (ID: {0}".format(database_item.id)) def _get_tables_for_database(self, database_item): - url = f"{self.baseurl}/{database_item.id}/tables" + url = "{0}/{1}/tables".format(self.baseurl, database_item.id) server_response = self.get_request(url) tables = TableItem.from_response(server_response.content, self.parent_srv.namespace) return tables @@ -128,7 +127,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..7f3a47075 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,8 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Mapping, Sequence +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.server.query import QuerySet @@ -23,7 +22,7 @@ from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -58,7 +57,7 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Datasources, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -66,11 +65,11 @@ def __init__(self, parent_srv: "Server") -> None: @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/datasources" + return "{0}/sites/{1}/datasources".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[DatasourceItem], PaginationItem]: logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -84,8 +83,8 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - logger.info(f"Querying single datasource (ID: {datasource_id})") - url = f"{self.baseurl}/{datasource_id}" + logger.info("Querying single datasource (ID: {0})".format(datasource_id)) + url = "{0}/{1}".format(self.baseurl, datasource_id) server_response = self.get_request(url) return DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -100,10 +99,10 @@ def connections_fetcher(): return self._get_datasource_connections(datasource_item) datasource_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") + logger.info("Populated connections for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_connections(self, datasource_item, req_options=None): - url = f"{self.baseurl}/{datasource_item.id}/connections" + url = "{0}/{1}/connections".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -114,9 +113,9 @@ def delete(self, datasource_id: str) -> None: if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{datasource_id}" + url = "{0}/{1}".format(self.baseurl, datasource_id) self.delete_request(url) - logger.info(f"Deleted single datasource (ID: {datasource_id})") + logger.info("Deleted single datasource (ID: {0})".format(datasource_id)) # Download 1 datasource by id @api(version="2.0") @@ -153,11 +152,11 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: self.update_tags(datasource_item) # Update the datasource itself - url = f"{self.baseurl}/{datasource_item.id}" + url = "{0}/{1}".format(self.baseurl, datasource_item.id) update_req = RequestFactory.Datasource.update_req(datasource_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated datasource item (ID: {datasource_item.id})") + logger.info("Updated datasource item (ID: {0})".format(datasource_item.id)) updated_datasource = copy.copy(datasource_item) return updated_datasource._parse_common_elements(server_response.content, self.parent_srv.namespace) @@ -166,7 +165,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: - url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, datasource_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) @@ -175,16 +174,18 @@ def update_connection( return None if len(connections) > 1: - logger.debug(f"Multiple connections returned ({len(connections)})") + logger.debug("Multiple connections returned ({0})".format(len(connections))) connection = list(filter(lambda x: x.id == connection_item.id, connections))[0] - logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") + logger.info( + "Updated datasource item (ID: {0} & connection item {1}".format(datasource_item.id, connection_item.id) + ) return connection @api(version="2.8") def refresh(self, datasource_item: DatasourceItem) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/refresh" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -193,7 +194,7 @@ def refresh(self, datasource_item: DatasourceItem) -> JobItem: @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -202,7 +203,7 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: id_ = getattr(datasource_item, "id", datasource_item) - url = f"{self.baseurl}/{id_}/deleteExtract" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @@ -222,12 +223,12 @@ def publish( if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] file_size = os.path.getsize(file) - logger.debug(f"Publishing file `{filename}`, size `{file_size}`") + logger.debug("Publishing file `{}`, size `{}`".format(filename, file_size)) # If name is not defined, grab the name from the file to publish if not datasource_item.name: datasource_item.name = os.path.splitext(filename)[0] @@ -246,10 +247,10 @@ def publish( elif file_type == "xml": file_extension = "tds" else: - error = f"Unsupported file type {file_type}" + error = "Unsupported file type {}".format(file_type) raise ValueError(error) - filename = f"{datasource_item.name}.{file_extension}" + filename = "{}.{}".format(datasource_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -260,27 +261,27 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?datasourceType={file_extension}" + url = "{0}?datasourceType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") # Determine if chunking is required (64MB is the limit for single upload method) - if file_size >= config.FILESIZE_LIMIT_MB * BYTES_PER_MB: + if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, config.FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Datasource.publish_req_chunked( datasource_item, connection_credentials, connections ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (Path, str)): with open(file, "rb") as f: @@ -308,11 +309,11 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(filename, new_job.id)) return new_job else: new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_datasource.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_datasource.id)) return new_datasource @api(version="3.13") @@ -326,23 +327,23 @@ def update_hyper_data( ) -> JobItem: if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_id) elif isinstance(datasource_or_connection_item, ConnectionItem): datasource_id = datasource_or_connection_item.datasource_id connection_id = datasource_or_connection_item.id - url = f"{self.baseurl}/{datasource_id}/connections/{connection_id}/data" + url = "{0}/{1}/connections/{2}/data".format(self.baseurl, datasource_id, connection_id) else: assert isinstance(datasource_or_connection_item, str) - url = f"{self.baseurl}/{datasource_or_connection_item}/data" + url = "{0}/{1}/data".format(self.baseurl, datasource_or_connection_item) if payload is not None: if not os.path.isfile(payload): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) - logger.info(f"Uploading {payload} to server with chunking method for Update job") + logger.info("Uploading {0} to server with chunking method for Update job".format(payload)) upload_session_id = self.parent_srv.fileuploads.upload(payload) - url = f"{url}?uploadSessionId={upload_session_id}" + url = "{0}?uploadSessionId={1}".format(url, upload_session_id) json_request = json.dumps({"actions": actions}) parameters = {"headers": {"requestid": request_id}} @@ -355,7 +356,7 @@ def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) @api(version="2.0") @@ -389,12 +390,12 @@ def revisions_fetcher(): return self._get_datasource_revisions(datasource_item) datasource_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") + logger.info("Populated revisions for datasource (ID: {0})".format(datasource_item.id)) def _get_datasource_revisions( self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{datasource_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, datasource_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions @@ -412,9 +413,9 @@ def download_revision( error = "Datasource ID undefined." raise ValueError(error) if revision_number is None: - url = f"{self.baseurl}/{datasource_id}/content" + url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: - url = f"{self.baseurl}/{datasource_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -436,7 +437,9 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") + logger.info( + "Downloaded datasource revision {0} to {1} (ID: {2})".format(revision_number, return_path, datasource_id) + ) return return_path @api(version="2.3") @@ -446,17 +449,19 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) self.delete_request(url) - logger.info(f"Deleted single datasource revision (ID: {datasource_id}) (Revision: {revision_number})") + logger.info( + "Deleted single datasource revision (ID: {0}) (Revision: {1})".format(datasource_id, revision_number) + ) # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 499324e8e..19112d713 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,8 +4,7 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, Optional, Union -from collections.abc import Sequence +from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Union if TYPE_CHECKING: from ..server import Server @@ -26,7 +25,7 @@ class _DefaultPermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_DefaultPermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent, a project or database. # It MUST be a lambda since we don't know the full site URL until we sign in. @@ -34,25 +33,23 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl()) __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] - ) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), resource.id, plural_type(content_type)) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated default {content_type} permissions for resource {resource.id}") + logger.info("Updated default {} permissions for resource {}".format(content_type, resource.id)) logger.info(permissions) return permissions - def delete_default_permission( - self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] - ) -> None: + def delete_default_permission(self, resource: BaseItem, rule: PermissionsRule, content_type: Resource) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better url = ( @@ -68,27 +65,29 @@ def delete_default_permission( ) ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) - def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) - def permission_fetcher() -> list[PermissionsRule]: + def permission_fetcher() -> List[PermissionsRule]: return self._get_default_permissions(item, content_type) item._set_default_permissions(permission_fetcher, content_type) - logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") + logger.info("Populated default {0} permissions for item (ID: {1})".format(content_type, item.id)) def _get_default_permissions( - self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None - ) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" + self, item: BaseItem, content_type: Resource, req_options: Optional["RequestOptions"] = None + ) -> List[PermissionsRule]: + url = "{0}/{1}/default-permissions/{2}".format(self.owner_baseurl(), item.id, plural_type(content_type)) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) logger.info({"content_type": content_type, "permissions": permissions}) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..5296523ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -10,35 +10,35 @@ class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): - super().__init__(parent_srv) + super(_DataQualityWarningEndpoint, self).__init__(parent_srv) self.resource_type = resource_type @property def baseurl(self): - return "{}/sites/{}/dataQualityWarnings/{}".format( + return "{0}/sites/{1}/dataQualityWarnings/{2}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) def add(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def update(self, resource, warning): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) warnings = DQWItem.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Added dqw for resource {resource.id}") + logger.info("Added dqw for resource {0}".format(resource.id)) return warnings def clear(self, resource): - url = f"{self.baseurl}/{resource.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=resource.id) return self.delete_request(url) def populate(self, item): @@ -50,10 +50,10 @@ def dqw_fetcher(): return self._get_data_quality_warnings(item) item._set_data_quality_warnings(dqw_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_data_quality_warnings(self, item, req_options=None): - url = f"{self.baseurl}/{item.id}" + url = "{baseurl}/{content_luid}".format(baseurl=self.baseurl, content_luid=item.id) server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..be0602df5 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -8,9 +8,12 @@ from typing import ( Any, Callable, + Dict, Generic, + List, Optional, TYPE_CHECKING, + Tuple, TypeVar, Union, ) @@ -19,7 +22,6 @@ from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.endpoint.exceptions import ( - FailedSignInError, ServerResponseError, InternalServerError, NonXMLResponseError, @@ -54,7 +56,7 @@ def __init__(self, parent_srv: "Server"): async_response = None @staticmethod - def set_parameters(http_options, auth_token, content, content_type, parameters) -> dict[str, Any]: + def set_parameters(http_options, auth_token, content, content_type, parameters) -> Dict[str, Any]: parameters = parameters or {} parameters.update(http_options) if "headers" not in parameters: @@ -80,7 +82,7 @@ def set_user_agent(parameters): else: # only set the TSC user agent if not already populated _client_version: Optional[str] = get_versions()["version"] - parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" + parameters["headers"][USER_AGENT_HEADER] = "Tableau Server Client/{}".format(_client_version) # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only @@ -88,12 +90,12 @@ def set_user_agent(parameters): def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: response = None - logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") + logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url)) try: response = method(url, **parameters) - logger.debug(f"[{datetime.timestamp()}] Call finished") + logger.debug("[{}] Call finished".format(datetime.timestamp())) except Exception as e: - logger.debug(f"Error making request to server: {e}") + logger.debug("Error making request to server: {}".format(e)) raise e return response @@ -109,13 +111,13 @@ def _make_request( content: Optional[bytes] = None, auth_token: Optional[str] = None, content_type: Optional[str] = None, - parameters: Optional[dict[str, Any]] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) - logger.debug(f"request method {method.__name__}, url: {url}") + logger.debug("request method {}, url: {}".format(method.__name__, url)) if content: redacted = helpers.strings.redact_xml(content[:200]) # this needs to be under a trace or something, it's a LOT @@ -127,21 +129,21 @@ def _make_request( server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) - logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") + logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response)) # is this blocking retry really necessary? I guess if it was just the threading messing it up? if server_response is None: logger.debug(server_response) - logger.debug(f"[{datetime.timestamp()}] Async request failed: retrying") + logger.debug("[{}] Async request failed: retrying".format(datetime.timestamp())) server_response = self._blocking_request(method, url, parameters) if server_response is None: - logger.debug(f"[{datetime.timestamp()}] Request failed") + logger.debug("[{}] Request failed".format(datetime.timestamp())) raise RuntimeError if isinstance(server_response, Exception): raise server_response self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) - logger.debug(f"Server response from {url}") + logger.debug("Server response from {0}".format(url)) # uncomment the following to log full responses in debug mode # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA # logger.debug(loggable_response) @@ -152,16 +154,16 @@ def _make_request( return server_response def _check_status(self, server_response: "Response", url: Optional[str] = None): - logger.debug(f"Response status: {server_response}") + logger.debug("Response status: {}".format(server_response)) if not hasattr(server_response, "status_code"): - raise OSError("Response is not a http response?") + raise EnvironmentError("Response is not a http response?") if server_response.status_code >= 500: raise InternalServerError(server_response, url) elif server_response.status_code not in Success_codes: try: if server_response.status_code == 401: # TODO: catch this in server.py and attempt to sign in again, in case it's a session expiry - raise FailedSignInError.from_response(server_response.content, self.parent_srv.namespace, url) + raise NotSignedInError(server_response.content, url) raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url) except ParseError: @@ -181,9 +183,9 @@ def log_response_safely(self, server_response: "Response") -> str: # content-type is an octet-stream accomplishes the same goal without eagerly loading content. # This check is to determine if the response is a text response (xml or otherwise) # so that we do not attempt to log bytes and other binary data. - loggable_response = f"Content type `{content_type}`" + loggable_response = "Content type `{}`".format(content_type) if content_type == "application/octet-stream": - loggable_response = f"A stream of type {content_type} [Truncated File Contents]" + loggable_response = "A stream of type {} [Truncated File Contents]".format(content_type) elif server_response.encoding and len(server_response.content) > 0: loggable_response = helpers.strings.redact_xml(server_response.content.decode(server_response.encoding)) return loggable_response @@ -311,7 +313,7 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: for p in params_to_check: min_ver = Version(str(params[p])) if server_ver < min_ver: - error = f"{p!r} not available in {server_ver}, it will be ignored. Added in {min_ver}" + error = "{!r} not available in {}, it will be ignored. Added in {}".format(p, server_ver, min_ver) warnings.warn(error) return func(self, *args, **kwargs) @@ -351,5 +353,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..9dfd38da6 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,31 +1,24 @@ from defusedxml.ElementTree import fromstring -from typing import Mapping, Optional, TypeVar - - -def split_pascal_case(s: str) -> str: - return "".join([f" {c}" if c.isupper() else c for c in s]).strip() +from typing import Optional class TableauError(Exception): pass -T = TypeVar("T") - - -class XMLError(TableauError): - def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: +class ServerResponseError(TableauError): + def __init__(self, code, summary, detail, url=None): self.code = code self.summary = summary self.detail = detail self.url = url - super().__init__(str(self)) + super(ServerResponseError, self).__init__(str(self)) def __str__(self): - return f"\n\n\t{self.code}: {self.summary}\n\t\t{self.detail}" + return "\n\n\t{0}: {1}\n\t\t{2}".format(self.code, self.summary, self.detail) @classmethod - def from_response(cls, resp, ns, url): + def from_response(cls, resp, ns, url=None): # Check elements exist before .text parsed_response = fromstring(resp) try: @@ -40,10 +33,6 @@ def from_response(cls, resp, ns, url): return error_response -class ServerResponseError(XMLError): - pass - - class InternalServerError(TableauError): def __init__(self, server_response, request_url: Optional[str] = None): self.code = server_response.status_code @@ -51,7 +40,7 @@ def __init__(self, server_response, request_url: Optional[str] = None): self.url = request_url or "server" def __str__(self): - return f"\n\nInternal error {self.code} at {self.url}\n{self.content}" + return "\n\nInternal error {0} at {1}\n{2}".format(self.code, self.url, self.content) class MissingRequiredFieldError(TableauError): @@ -62,11 +51,6 @@ class NotSignedInError(TableauError): pass -class FailedSignInError(XMLError, NotSignedInError): - def __str__(self): - return f"{split_pascal_case(self.__class__.__name__)}: {super().__str__()}" - - class ItemTypeNotAllowed(TableauError): pass diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 8330e6d2c..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -20,13 +20,13 @@ class Favorites(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" + return "{0}/sites/{1}/favorites".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - logger.info(f"Querying all favorites for user {user_item.name}") - url = f"{self.baseurl}/{user_item.id}" + logger.info("Querying all favorites for user {0}".format(user_item.name)) + url = "{0}/{1}".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) user_item._favorites = FavoriteItem.from_response(server_response.content, self.parent_srv.namespace) @@ -34,53 +34,53 @@ def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(item.name, user_item.id)) return server_response @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {workbook_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(workbook_item.name, user_item.id)) @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {view_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(view_item.name, user_item.id)) @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {datasource_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(datasource_item.name, user_item.id)) @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {project_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(project_item.name, user_item.id)) @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited {flow_item.name} for user (ID: {user_item.id})") + logger.info("Favorited {0} for user (ID: {1})".format(flow_item.name, user_item.id)) @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) - logger.info(f"Favorited metric {metric_item.name} for user (ID: {user_item.id})") + logger.info("Favorited metric {0} for user (ID: {1})".format(metric_item.name, user_item.id)) # ------- delete from favorites # Response: @@ -94,42 +94,42 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: - url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" - logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") + url = "{0}/{1}/{2}/{3}".format(self.baseurl, user_item.id, content_type, item.id) + logger.info("Removing favorite {0}({1}) for user (ID: {2})".format(content_type, item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: - url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" - logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/workbooks/{2}".format(self.baseurl, user_item.id, workbook_item.id) + logger.info("Removing favorite workbook {0} for user (ID: {1})".format(workbook_item.id, user_item.id)) self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: - url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" - logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/views/{2}".format(self.baseurl, user_item.id, view_item.id) + logger.info("Removing favorite view {0} for user (ID: {1})".format(view_item.id, user_item.id)) self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: - url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" - logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/datasources/{2}".format(self.baseurl, user_item.id, datasource_item.id) + logger.info("Removing favorite {0} for user (ID: {1})".format(datasource_item.id, user_item.id)) self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: - url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" - logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/projects/{2}".format(self.baseurl, user_item.id, project_item.id) + logger.info("Removing favorite project {0} for user (ID: {1})".format(project_item.id, user_item.id)) self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: - url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" - logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/flows/{2}".format(self.baseurl, user_item.id, flow_item.id) + logger.info("Removing favorite flow {0} for user (ID: {1})".format(flow_item.id, user_item.id)) self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: - url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" - logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") + url = "{0}/{1}/metrics/{2}".format(self.baseurl, user_item.id, metric_item.id) + logger.info("Removing favorite metric {0} for user (ID: {1})".format(metric_item.id, user_item.id)) self.delete_request(url) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..0d30797c1 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -9,11 +9,11 @@ class Fileuploads(Endpoint): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Fileuploads, self).__init__(parent_srv) @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/fileUploads" + return "{0}/sites/{1}/fileUploads".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") def initiate(self): @@ -21,14 +21,14 @@ def initiate(self): server_response = self.post_request(url, "") fileupload_item = FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) upload_id = fileupload_item.upload_session_id - logger.info(f"Initiated file upload session (ID: {upload_id})") + logger.info("Initiated file upload session (ID: {0})".format(upload_id)) return upload_id @api(version="2.0") def append(self, upload_id, data, content_type): - url = f"{self.baseurl}/{upload_id}" + url = "{0}/{1}".format(self.baseurl, upload_id) server_response = self.put_request(url, data, content_type) - logger.info(f"Uploading a chunk to session (ID: {upload_id})") + logger.info("Uploading a chunk to session (ID: {0})".format(upload_id)) return FileuploadItem.from_response(server_response.content, self.parent_srv.namespace) def _read_chunks(self, file): @@ -52,10 +52,12 @@ def _read_chunks(self, file): def upload(self, file): upload_id = self.initiate() for chunk in self._read_chunks(file): - logger.debug(f"{datetime.timestamp()} processing chunk...") + logger.debug("{} processing chunk...".format(datetime.timestamp())) request, content_type = RequestFactory.Fileupload.chunk_req(chunk) - logger.debug(f"{datetime.timestamp()} created chunk request") + logger.debug("{} created chunk request".format(datetime.timestamp())) fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") - logger.info(f"File upload finished (ID: {upload_id})") + logger.info( + "\t{0} Published {1}MB".format(datetime.timestamp(), (fileupload_item.file_size / BYTES_PER_MB)) + ) + logger.info("File upload finished (ID: {0})".format(upload_id)) return upload_id diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2c3bb84bc..c339a0645 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,9 +1,9 @@ import logging -from typing import Optional, TYPE_CHECKING, Union +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException -from tableauserverclient.models import FlowRunItem +from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -16,24 +16,22 @@ class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(FlowRuns, self).__init__(parent_srv) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows/runs" + return "{0}/sites/{1}/flows/runs".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.10") - # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns - # does not return a PaginationItem. Suppressing the mypy error because the - # changes to the QuerySet class should permit this to function regardless. - def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowRunItem], PaginationItem]: logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_flow_run_items = FlowRunItem.from_response(server_response.content, self.parent_srv.namespace) - return all_flow_run_items + return all_flow_run_items, pagination_item # Get 1 flow by id @api(version="3.10") @@ -41,21 +39,21 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_run_id})") - url = f"{self.baseurl}/{flow_run_id}" + logger.info("Querying single flow (ID: {0})".format(flow_run_id)) + url = "{0}/{1}".format(self.baseurl, flow_run_id) server_response = self.get_request(url) return FlowRunItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: + def cancel(self, flow_run_id: str) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) id_ = getattr(flow_run_id, "id", flow_run_id) - url = f"{self.baseurl}/{id_}" + url = "{0}/{1}".format(self.baseurl, id_) self.put_request(url) - logger.info(f"Deleted single flow (ID: {id_})") + logger.info("Deleted single flow (ID: {0})".format(id_)) @api(version="3.10") def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: @@ -71,7 +69,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl flow_run = self.get_by_id(flow_run_id) logger.debug(f"\tFlowRun {flow_run_id} progress={flow_run.progress}") - logger.info(f"FlowRun {flow_run_id} Completed: Status: {flow_run.status}") + logger.info("FlowRun {} Completed: Status: {}".format(flow_run_id, flow_run.status)) if flow_run.status == "Success": return flow_run diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 9e21661e6..eea3f9710 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class FlowTasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" + return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.22") def create(self, flow_item: TaskItem) -> TaskItem: diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..53d072f50 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,8 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.helpers.headers import fix_filename @@ -54,18 +53,18 @@ class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "flow") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/flows" + return "{0}/sites/{1}/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[FlowItem], PaginationItem]: logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -79,8 +78,8 @@ def get_by_id(self, flow_id: str) -> FlowItem: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - logger.info(f"Querying single flow (ID: {flow_id})") - url = f"{self.baseurl}/{flow_id}" + logger.info("Querying single flow (ID: {0})".format(flow_id)) + url = "{0}/{1}".format(self.baseurl, flow_id) server_response = self.get_request(url) return FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -95,10 +94,10 @@ def connections_fetcher(): return self._get_flow_connections(flow_item) flow_item._set_connections(connections_fetcher) - logger.info(f"Populated connections for flow (ID: {flow_item.id})") + logger.info("Populated connections for flow (ID: {0})".format(flow_item.id)) - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: - url = f"{self.baseurl}/{flow_item.id}/connections" + def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, flow_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -109,9 +108,9 @@ def delete(self, flow_id: str) -> None: if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}" + url = "{0}/{1}".format(self.baseurl, flow_id) self.delete_request(url) - logger.info(f"Deleted single flow (ID: {flow_id})") + logger.info("Deleted single flow (ID: {0})".format(flow_id)) # Download 1 flow by id @api(version="3.3") @@ -119,7 +118,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path if not flow_id: error = "Flow ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{flow_id}/content" + url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() @@ -138,7 +137,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") + logger.info("Downloaded flow to {0} (ID: {1})".format(return_path, flow_id)) return return_path # Update flow @@ -151,28 +150,28 @@ def update(self, flow_item: FlowItem) -> FlowItem: self._resource_tagger.update_tags(self.baseurl, flow_item) # Update the flow itself - url = f"{self.baseurl}/{flow_item.id}" + url = "{0}/{1}".format(self.baseurl, flow_item.id) update_req = RequestFactory.Flow.update_req(flow_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated flow item (ID: {flow_item.id})") + logger.info("Updated flow item (ID: {0})".format(flow_item.id)) updated_flow = copy.copy(flow_item) return updated_flow._parse_common_elements(server_response.content, self.parent_srv.namespace) # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: - url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, flow_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated flow item (ID: {flow_item.id} & connection item {connection_item.id}") + logger.info("Updated flow item (ID: {0} & connection item {1}".format(flow_item.id, connection_item.id)) return connection @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: - url = f"{self.baseurl}/{flow_item.id}/run" + url = "{0}/{1}/run".format(self.baseurl, flow_item.id) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -181,7 +180,7 @@ def refresh(self, flow_item: FlowItem) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[List[ConnectionItem]] = None ) -> FlowItem: if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." @@ -190,7 +189,7 @@ def publish( if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -214,30 +213,30 @@ def publish( elif file_type == "xml": file_extension = "tfl" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the flow in a single request - filename = f"{flow_item.name}.{file_extension}" + filename = "{}.{}".format(flow_item.name, file_extension) file_size = get_file_object_size(file) else: raise TypeError("file should be a filepath or file object.") # Construct the url with the defined mode - url = f"{self.baseurl}?flowType={file_extension}" + url = "{0}?flowType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {filename} to server with chunking method (flow over 64MB)") + logger.info("Publishing {0} to server with chunking method (flow over 64MB)".format(filename)) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Flow.publish_req_chunked(flow_item, connections) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -260,7 +259,7 @@ def publish( raise err else: new_flow = FlowItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {filename} (ID: {new_flow.id})") + logger.info("Published {0} (ID: {1})".format(filename, new_flow.id)) return new_flow @api(version="3.3") @@ -295,7 +294,7 @@ def delete_dqw(self, item: FlowItem) -> None: @api(version="3.3") def schedule_flow_run( self, schedule_id: str, item: FlowItem - ) -> list["AddResponse"]: # actually should return a task + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..8acf31692 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,8 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union from tableauserverclient.server.query import QuerySet @@ -20,10 +19,10 @@ class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" + return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl @@ -51,12 +50,12 @@ def user_pager(): def _get_users_for_group( self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None - ) -> tuple[list[UserItem], PaginationItem]: - url = f"{self.baseurl}/{group_item.id}/users" + ) -> Tuple[List[UserItem], PaginationItem]: + url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) user_item = UserItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Populated users for group (ID: {group_item.id})") + logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item @api(version="2.0") @@ -65,13 +64,13 @@ def delete(self, group_id: str) -> None: if not group_id: error = "Group ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_id}" + url = "{0}/{1}".format(self.baseurl, group_id) self.delete_request(url) - logger.info(f"Deleted single group (ID: {group_id})") + logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - url = f"{self.baseurl}/{group_item.id}" + url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: error = "Group item missing ID." @@ -84,7 +83,7 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated group item (ID: {group_item.id})") + logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] else: @@ -119,9 +118,9 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users/{user_id}" + url = "{0}/{1}/users/{2}".format(self.baseurl, group_item.id, user_id) self.delete_request(url) - logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") + logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: @@ -133,7 +132,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte url = f"{self.baseurl}/{group_id}/users/remove" add_req = RequestFactory.Group.remove_users_req(users) _ = self.put_request(url, add_req) - logger.info(f"Removed users to group (ID: {group_item.id})") + logger.info("Removed users to group (ID: {0})".format(group_item.id)) return None @api(version="2.0") @@ -145,15 +144,15 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{group_item.id}/users" + url = "{0}/{1}/users".format(self.baseurl, group_item.id) add_req = RequestFactory.Group.add_user_req(user_id) server_response = self.post_request(url, add_req) user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added user (id: {user_id}) to group (ID: {group_item.id})") + logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): @@ -163,7 +162,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] add_req = RequestFactory.Group.add_users_req(users) server_response = self.post_request(url, add_req) users = UserItem.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Added users to group (ID: {group_item.id})") + logger.info("Added users to group (ID: {0})".format(group_item.id)) return users def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..06e7cc627 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TYPE_CHECKING, Union +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -27,7 +27,7 @@ def get( self, request_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, - ) -> tuple[list[GroupSetItem], PaginationItem]: + ) -> Tuple[List[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..ae8cf2633 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,24 +11,24 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, Union +from typing import List, Optional, Tuple, Union class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" + return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) @overload # type: ignore[override] def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -53,13 +53,13 @@ def cancel(self, job_id: Union[str, JobItem]): if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) return self.put_request(url) @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: logger.info("Query for information about job " + job_id) - url = f"{self.baseurl}/{job_id}" + url = "{0}/{1}".format(self.baseurl, job_id) server_response = self.get_request(url) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -77,7 +77,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] job = self.get_by_id(job_id) logger.debug(f"\tJob {job_id} progress={job.progress}") - logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") + logger.info("Job {} Completed: Finish Code: {} - Notes:{}".format(job_id, job.finish_code, job.notes)) if job.finish_code == JobItem.FinishCode.Success: return job diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ede4d38e3..374130509 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Tuple, Union from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index e5dbcbcf8..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -50,11 +50,11 @@ def get_page_info(result): class Metadata(Endpoint): @property def baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/graphql" + return "{0}/api/metadata/graphql".format(self.parent_srv.server_address) @property def control_baseurl(self): - return f"{self.parent_srv.server_address}/api/metadata/v1/control" + return "{0}/api/metadata/v1/control".format(self.parent_srv.server_address) @api("3.5") def query(self, query, variables=None, abort_on_error=False, parameters=None): diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 3fea1f5b6..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -20,18 +20,18 @@ class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "metric") @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/metrics" + return "{0}/sites/{1}/metrics".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -45,8 +45,8 @@ def get_by_id(self, metric_id: str) -> MetricItem: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - logger.info(f"Querying single metric (ID: {metric_id})") - url = f"{self.baseurl}/{metric_id}" + logger.info("Querying single metric (ID: {0})".format(metric_id)) + url = "{0}/{1}".format(self.baseurl, metric_id) server_response = self.get_request(url) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -56,9 +56,9 @@ def delete(self, metric_id: str) -> None: if not metric_id: error = "Metric ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{metric_id}" + url = "{0}/{1}".format(self.baseurl, metric_id) self.delete_request(url) - logger.info(f"Deleted single metric (ID: {metric_id})") + logger.info("Deleted single metric (ID: {0})".format(metric_id)) # Update metric @api(version="3.9") @@ -70,8 +70,8 @@ def update(self, metric_item: MetricItem) -> MetricItem: self._resource_tagger.update_tags(self.baseurl, metric_item) # Update the metric itself - url = f"{self.baseurl}/{metric_item.id}" + url = "{0}/{1}".format(self.baseurl, metric_item.id) update_req = RequestFactory.Metric.update_req(metric_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated metric item (ID: {metric_item.id})") + logger.info("Updated metric item (ID: {0})".format(metric_item.id)) return MetricItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10d420ff7..4433625f2 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, Optional, Union +from typing import Callable, TYPE_CHECKING, List, Optional, Union from tableauserverclient.helpers.logging import logger @@ -25,7 +25,7 @@ class _PermissionsEndpoint(Endpoint): """ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> None: - super().__init__(parent_srv) + super(_PermissionsEndpoint, self).__init__(parent_srv) # owner_baseurl is the baseurl of the parent. The MUST be a lambda # since we don't know the full site URL until we sign in. If @@ -33,18 +33,18 @@ def __init__(self, parent_srv: "Server", owner_baseurl: Callable[[], str]) -> No self.owner_baseurl = owner_baseurl def __str__(self): - return f"" + return "".format(self.owner_baseurl) - def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> list[PermissionsRule]: - url = f"{self.owner_baseurl()}/{resource.id}/permissions" + def update(self, resource: TableauItem, permissions: List[PermissionsRule]) -> List[PermissionsRule]: + url = "{0}/{1}/permissions".format(self.owner_baseurl(), resource.id) update_req = RequestFactory.Permission.add_req(permissions) response = self.put_request(url, update_req) permissions = PermissionsRule.from_response(response.content, self.parent_srv.namespace) - logger.info(f"Updated permissions for resource {resource.id}: {permissions}") + logger.info("Updated permissions for resource {0}: {1}".format(resource.id, permissions)) return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: Union[PermissionsRule, List[PermissionsRule]]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -54,7 +54,7 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi for rule in rules: for capability, mode in rule.capabilities.items(): "/permissions/groups/group-id/capability-name/capability-mode" - url = "{}/{}/permissions/{}/{}/{}/{}".format( + url = "{0}/{1}/permissions/{2}/{3}/{4}/{5}".format( self.owner_baseurl(), resource.id, rule.grantee.tag_name + "s", @@ -63,11 +63,13 @@ def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[Permi mode, ) - logger.debug(f"Removing {mode} permission for capability {capability}") + logger.debug("Removing {0} permission for capability {1}".format(mode, capability)) self.delete_request(url) - logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") + logger.info( + "Deleted permission for {0} {1} item {2}".format(rule.grantee.tag_name, rule.grantee.id, resource.id) + ) def populate(self, item: TableauItem): if not item.id: @@ -78,12 +80,12 @@ def permission_fetcher(): return self._get_permissions(item) item._set_permissions(permission_fetcher) - logger.info(f"Populated permissions for item (ID: {item.id})") + logger.info("Populated permissions for item (ID: {0})".format(item.id)) def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): - url = f"{self.owner_baseurl()}/{item.id}/permissions" + url = "{0}/{1}/permissions".format(self.owner_baseurl(), item.id) server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) - logger.info(f"Permissions for resource {item.id}: {permissions}") + logger.info("Permissions for resource {0}: {1}".format(item.id, permissions)) return permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..565817e37 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,10 +5,9 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions -from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -21,17 +20,17 @@ class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Projects, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._default_permissions = _DefaultPermissionsEndpoint(parent_srv, lambda: self.baseurl) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" + return "{0}/sites/{1}/projects".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ProjectItem], PaginationItem]: logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,9 +43,9 @@ def delete(self, project_id: str) -> None: if not project_id: error = "Project ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{project_id}" + url = "{0}/{1}".format(self.baseurl, project_id) self.delete_request(url) - logger.info(f"Deleted single project (ID: {project_id})") + logger.info("Deleted single project (ID: {0})".format(project_id)) @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: @@ -55,10 +54,10 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte raise MissingRequiredFieldError(error) params = {"params": {RequestOptions.Field.PublishSamples: samples}} - url = f"{self.baseurl}/{project_item.id}" + url = "{0}/{1}".format(self.baseurl, project_item.id) update_req = RequestFactory.Project.update_req(project_item) server_response = self.put_request(url, update_req, XML_CONTENT_TYPE, params) - logger.info(f"Updated project item (ID: {project_item.id})") + logger.info("Updated project item (ID: {0})".format(project_item.id)) updated_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_project @@ -67,11 +66,11 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: - url = f"{self.baseurl}?publishSamples={project_item._samples}" + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new project (ID: {new_project.id})") + logger.info("Created new project (ID: {0})".format(new_project.id)) return new_project @api(version="2.0") @@ -79,135 +78,85 @@ def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_permissions(self, item, rules): return self._permissions.update(item, rules) @api(version="2.0") - def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + def delete_permission(self, item, rules): self._permissions.delete(item, rules) @api(version="2.1") - def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + def populate_workbook_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") - def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + def populate_datasource_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") - def populate_metric_default_permissions(self, item: ProjectItem) -> None: + def populate_metric_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") - def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + def populate_datarole_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") - def populate_flow_default_permissions(self, item: ProjectItem) -> None: + def populate_flow_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") - def populate_lens_default_permissions(self, item: ProjectItem) -> None: + def populate_lens_default_permissions(self, item): self._default_permissions.populate_default_permissions(item, Resource.Lens) - @api(version="3.23") - def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) - - @api(version="3.23") - def populate_database_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.Database) - - @api(version="3.23") - def populate_table_default_permissions(self, item: ProjectItem) -> None: - self._default_permissions.populate_default_permissions(item, Resource.Table) - @api(version="2.1") - def update_workbook_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_workbook_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") - def update_datasource_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datasource_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") - def update_metric_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_metric_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") - def update_datarole_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: + def update_datarole_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") - def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_flow_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") - def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + def update_lens_default_permissions(self, item, rules): return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) - @api(version="3.23") - def update_virtualconnection_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) - - @api(version="3.23") - def update_database_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.Database) - - @api(version="3.23") - def update_table_default_permissions( - self, item: ProjectItem, rules: list[PermissionsRule] - ) -> list[PermissionsRule]: - return self._default_permissions.update_default_permissions(item, rules, Resource.Table) - @api(version="2.1") - def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_workbook_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") - def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datasource_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") - def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_metric_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") - def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_datarole_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") - def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_flow_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") - def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) - @api(version="3.23") - def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) - - @api(version="3.23") - def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.Database) - - @api(version="3.23") - def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: - self._default_permissions.delete_default_permission(item, rule, Resource.Table) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 63c03b3e3..1894e3b8a 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,7 +1,6 @@ import abc import copy -from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable -from collections.abc import Iterable +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse from tableauserverclient.server.endpoint.endpoint import Endpoint, api @@ -25,7 +24,7 @@ class _ResourceTagger(Endpoint): # Add new tags to resource def _add_tags(self, baseurl, resource_id, tag_set): - url = f"{baseurl}/{resource_id}/tags" + url = "{0}/{1}/tags".format(baseurl, resource_id) add_req = RequestFactory.Tag.add_req(tag_set) try: @@ -40,7 +39,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): encoded_tag_name = urllib.parse.quote(tag_name) - url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" + url = "{0}/{1}/tags/{2}".format(baseurl, resource_id, encoded_tag_name) try: self.delete_request(url) @@ -60,7 +59,7 @@ def update_tags(self, baseurl, resource_item): if add_set: resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) - logger.info(f"Updated tags to {resource_item.tags}") + logger.info("Updated tags to {0}".format(resource_item.tags)) class Response(Protocol): @@ -69,8 +68,8 @@ class Response(Protocol): @runtime_checkable class Taggable(Protocol): - tags: set[str] - _initial_tags: set[str] + tags: Set[str] + _initial_tags: Set[str] @property def id(self) -> Optional[str]: @@ -96,14 +95,14 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -119,7 +118,7 @@ def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> N raise ValueError("ID not found.") if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -159,9 +158,9 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) @@ -171,9 +170,9 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: if isinstance(tags, str): - tag_set = {tags} + tag_set = set([tags]) else: tag_set = set(tags) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..cfaee3324 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -2,7 +2,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, Optional, Union +from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -22,14 +22,14 @@ class Schedules(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/schedules" + return "{0}/schedules".format(self.parent_srv.baseurl) @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" + return "{0}/sites/{1}/schedules".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[ScheduleItem], PaginationItem]: logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -42,8 +42,8 @@ def get_by_id(self, schedule_id): if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) - logger.info(f"Querying a single schedule by id ({schedule_id})") - url = f"{self.baseurl}/{schedule_id}" + logger.info("Querying a single schedule by id ({})".format(schedule_id)) + url = "{0}/{1}".format(self.baseurl, schedule_id) server_response = self.get_request(url) return ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -52,9 +52,9 @@ def delete(self, schedule_id: str) -> None: if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) - url = f"{self.baseurl}/{schedule_id}" + url = "{0}/{1}".format(self.baseurl, schedule_id) self.delete_request(url) - logger.info(f"Deleted single schedule (ID: {schedule_id})") + logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @@ -62,10 +62,10 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{schedule_item.id}" + url = "{0}/{1}".format(self.baseurl, schedule_item.id) update_req = RequestFactory.Schedule.update_req(schedule_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated schedule item (ID: {schedule_item.id})") + logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) updated_schedule = copy.copy(schedule_item) return updated_schedule._parse_common_tags(server_response.content, self.parent_srv.namespace) @@ -79,7 +79,7 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: create_req = RequestFactory.Schedule.create_req(schedule_item) server_response = self.post_request(url, create_req) new_schedule = ScheduleItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new schedule (ID: {new_schedule.id})") + logger.info("Created new schedule (ID: {})".format(new_schedule.id)) return new_schedule @api(version="2.8") @@ -91,12 +91,12 @@ def add_to_schedule( datasource: Optional["DatasourceItem"] = None, flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, - ) -> list[AddResponse]: + ) -> List[AddResponse]: # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) - items: list[ - tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + items: List[ + Tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] ] = [] if workbook is not None: @@ -115,7 +115,8 @@ def add_to_schedule( ) # type:ignore[arg-type] results = (self._add_to(*x) for x in items) - return [x for x in results if not x.result] + # list() is needed for python 3.x compatibility + return list(filter(lambda x: not x.result, results)) # type:ignore[arg-type] def _add_to( self, @@ -132,13 +133,13 @@ def _add_to( item_task_type, ) -> AddResponse: id_ = resource.id - url = f"{self.siteurl}/{schedule_id}/{type_}s" + url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) add_req = req_factory(id_, task_type=item_task_type) # type: ignore[call-arg, arg-type] response = self.put_request(url, add_req) error, warnings, task_created = ScheduleItem.parse_add_to_schedule_response(response, self.parent_srv.namespace) if task_created: - logger.info(f"Added {type_} to {id_} to schedule {schedule_id}") + logger.info("Added {} to {} to schedule {}".format(type_, id_, schedule_id)) if error is not None or warnings is not None: return AddResponse( diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index dc934496a..26aaf2910 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -1,5 +1,4 @@ import logging -from typing import Union from .endpoint import Endpoint, api from .exceptions import ServerResponseError @@ -22,49 +21,15 @@ def serverInfo(self): return self._info def __repr__(self): - return f"" + return "".format(self.serverInfo) @property - def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/serverInfo" + def baseurl(self): + return "{0}/serverInfo".format(self.parent_srv.baseurl) @api(version="2.4") - def get(self) -> Union[ServerInfoItem, None]: - """ - Retrieve the build and version information for the server. - - This method makes an unauthenticated call, so no sign in or - authentication token is required. - - Returns - ------- - :class:`~tableauserverclient.models.ServerInfoItem` - - Raises - ------ - :class:`~tableauserverclient.exceptions.ServerInfoEndpointNotFoundError` - Raised when the server info endpoint is not found. - - :class:`~tableauserverclient.exceptions.EndpointUnavailableError` - Raised when the server info endpoint is not available. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create a instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # set the version number > 2.3 - >>> # the server_info.get() method works in 2.4 and later - >>> server.version = '2.5' - - >>> s_info = server.server_info.get() - >>> print("\nServer info:") - >>> print("\tProduct version: {0}".format(s_info.product_version)) - >>> print("\tREST API version: {0}".format(s_info.rest_api_version)) - >>> print("\tBuild number: {0}".format(s_info.build_number)) - """ + def get(self): + """Retrieve the server info for the server. This is an unauthenticated call""" try: server_response = self.get_unauthenticated_request(self.baseurl) except ServerResponseError as e: diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..dfec49ae1 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,49 +8,20 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions class Sites(Endpoint): - """ - Using the site methods of the Tableau Server REST API you can: - - List sites on a server or get details of a specific site - Create, update, or delete a site - List views in a site - Encrypt, decrypt, or reencrypt extracts on a site - - """ - @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites" + return "{0}/sites".format(self.parent_srv.baseurl) # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: - """ - Query all sites on the server. This method requires server admin - permissions. This endpoint is paginated, meaning that the server will - only return a subset of the data at a time. The response will contain - information about the total number of sites and the number of sites - returned in the current response. Use the PaginationItem object to - request more data. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_sites - - Parameters - ---------- - req_options : RequestOptions, optional - Filtering options for the request. - - Returns - ------- - tuple[list[SiteItem], PaginationItem] - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SiteItem], PaginationItem]: logger.info("Querying all sites on site") logger.info("Requires Server Admin permissions") url = self.baseurl @@ -62,33 +33,6 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Site # Gets 1 site by id @api(version="2.0") def get_by_id(self, site_id: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - site_id : str - The site ID. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site ID is not defined. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - Examples - -------- - >>> site = server.sites.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) @@ -96,45 +40,20 @@ def get_by_id(self, site_id: str) -> SiteItem: error = "You can only retrieve the site for which you are currently authenticated." raise ValueError(error) - logger.info(f"Querying single site (ID: {site_id})") - url = f"{self.baseurl}/{site_id}" + logger.info("Querying single site (ID: {0})".format(site_id)) + url = "{0}/{1}".format(self.baseurl, site_id) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Gets 1 site by name @api(version="2.0") def get_by_name(self, site_name: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - site_name : str - The site name. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site name is not defined. - - Examples - -------- - >>> site = server.sites.get_by_name('Tableau') - - """ if not site_name: error = "Site Name undefined." raise ValueError(error) print("Note: You can only work with the site for which you are currently authenticated") - logger.info(f"Querying single site (Name: {site_name})") - url = f"{self.baseurl}/{site_name}?key=name" + logger.info("Querying single site (Name: {0})".format(site_name)) + url = "{0}/{1}?key=name".format(self.baseurl, site_name) print(self.baseurl, url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -142,31 +61,6 @@ def get_by_name(self, site_name: str) -> SiteItem: # Gets 1 site by content url @api(version="2.0") def get_by_content_url(self, content_url: str) -> SiteItem: - """ - Query a single site on the server. You can only retrieve the site that - you are currently authenticated for. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_site - - Parameters - ---------- - content_url : str - The content URL. - - Returns - ------- - SiteItem - - Raises - ------ - ValueError - If the site name is not defined. - - Examples - -------- - >>> site = server.sites.get_by_name('Tableau') - - """ if content_url is None: error = "Content URL undefined." raise ValueError(error) @@ -174,51 +68,15 @@ def get_by_content_url(self, content_url: str) -> SiteItem: error = "You can only work with the site you are currently authenticated for" raise ValueError(error) - logger.info(f"Querying single site (Content URL: {content_url})") + logger.info("Querying single site (Content URL: {0})".format(content_url)) logger.debug("Querying other sites requires Server Admin permissions") - url = f"{self.baseurl}/{content_url}?key=contentUrl" + url = "{0}/{1}?key=contentUrl".format(self.baseurl, content_url) server_response = self.get_request(url) return SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] # Update site @api(version="2.0") def update(self, site_item: SiteItem) -> SiteItem: - """ - Modifies the settings for site. - - The site item object must include the site ID and overrides all other settings. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_site - - Parameters - ---------- - site_item : SiteItem - The site item that you want to update. The settings specified in the - site item override the current site settings. - - Returns - ------- - SiteItem - The site item object that was updated. - - Raises - ------ - MissingRequiredFieldError - If the site item is missing an ID. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - ValueError - If the site admin mode is set to ContentOnly and a user quota is also set. - - Examples - -------- - >>> ... - >>> site_item.name = 'New Name' - >>> updated_site = server.sites.update(site_item) - - """ if not site_item.id: error = "Site item missing ID." raise MissingRequiredFieldError(error) @@ -232,94 +90,30 @@ def update(self, site_item: SiteItem) -> SiteItem: error = "You cannot set admin_mode to ContentOnly and also set a user quota" raise ValueError(error) - url = f"{self.baseurl}/{site_item.id}" + url = "{0}/{1}".format(self.baseurl, site_item.id) update_req = RequestFactory.Site.update_req(site_item, self.parent_srv) server_response = self.put_request(url, update_req) - logger.info(f"Updated site item (ID: {site_item.id})") + logger.info("Updated site item (ID: {0})".format(site_item.id)) update_site = copy.copy(site_item) return update_site._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 site object @api(version="2.0") def delete(self, site_id: str) -> None: - """ - Deletes the specified site from the server. You can only delete the site - if you are a Server Admin. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_site - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - ValueError - If the site ID does not match the site for which you are currently authenticated. - - Examples - -------- - >>> server.sites.delete('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}" + url = "{0}/{1}".format(self.baseurl, site_id) if not site_id == self.parent_srv.site_id: error = "You can only delete the site you are currently authenticated for" raise ValueError(error) self.delete_request(url) self.parent_srv._clear_auth() - logger.info(f"Deleted single site (ID: {site_id}) and signed out") + logger.info("Deleted single site (ID: {0}) and signed out".format(site_id)) # Create new site @api(version="2.0") def create(self, site_item: SiteItem) -> SiteItem: - """ - Creates a new site on the server for the specified site item object. - - Tableau Server only. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_site - - Parameters - ---------- - site_item : SiteItem - The settings for the site that you want to create. You need to - create an instance of SiteItem and pass it to the create method. - - Returns - ------- - SiteItem - The site item object that was created. - - Raises - ------ - ValueError - If the site admin mode is set to ContentOnly and a user quota is also set. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create an instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # create shortcut for admin mode - >>> content_users=TSC.SiteItem.AdminMode.ContentAndUsers - - >>> # create a new SiteItem - >>> new_site = TSC.SiteItem(name='Tableau', content_url='tableau', admin_mode=content_users, user_quota=15, storage_quota=1000, disable_subscriptions=True) - - >>> # call the sites create method with the SiteItem - >>> new_site = server.sites.create(new_site) - - - """ if site_item.admin_mode: if site_item.admin_mode == SiteItem.AdminMode.ContentOnly and site_item.user_quota: error = "You cannot set admin_mode to ContentOnly and also set a user quota" @@ -329,92 +123,33 @@ def create(self, site_item: SiteItem) -> SiteItem: create_req = RequestFactory.Site.create_req(site_item, self.parent_srv) server_response = self.post_request(url, create_req) new_site = SiteItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new site (ID: {new_site.id})") + logger.info("Created new site (ID: {0})".format(new_site.id)) return new_site @api(version="3.5") def encrypt_extracts(self, site_id: str) -> None: - """ - Encrypts all extracts on the site. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#encrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/encrypt-extracts" + url = "{0}/{1}/encrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def decrypt_extracts(self, site_id: str) -> None: - """ - Decrypts all extracts on the site. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#decrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.decrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/decrypt-extracts" + url = "{0}/{1}/decrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) @api(version="3.5") def re_encrypt_extracts(self, site_id: str) -> None: - """ - Reencrypt all extracts on a site with new encryption keys. If no site is - specified, extracts on the default site will be reencrypted. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#reencrypt_extracts - - Parameters - ---------- - site_id : str - The site ID. - - Raises - ------ - ValueError - If the site ID is not defined. - - Examples - -------- - >>> server.sites.re_encrypt_extracts('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') - - """ if not site_id: error = "Site ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{site_id}/reencrypt-extracts" + url = "{0}/{1}/reencrypt-extracts".format(self.baseurl, site_id) empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index c9abc9b06..a9f2e7bf5 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..request_options import RequestOptions @@ -16,10 +16,10 @@ class Subscriptions(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" + return "{0}/sites/{1}/subscriptions".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -33,8 +33,8 @@ def get_by_id(self, subscription_id: str) -> SubscriptionItem: if not subscription_id: error = "No Subscription ID provided" raise ValueError(error) - logger.info(f"Querying a single subscription by id ({subscription_id})") - url = f"{self.baseurl}/{subscription_id}" + logger.info("Querying a single subscription by id ({})".format(subscription_id)) + url = "{}/{}".format(self.baseurl, subscription_id) server_response = self.get_request(url) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -43,7 +43,7 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item: error = "No Susbcription provided" raise ValueError(error) - logger.info(f"Creating a subscription ({subscription_item})") + logger.info("Creating a subscription ({})".format(subscription_item)) url = self.baseurl create_req = RequestFactory.Subscription.create_req(subscription_item) server_response = self.post_request(url, create_req) @@ -54,17 +54,17 @@ def delete(self, subscription_id: str) -> None: if not subscription_id: error = "Subscription ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{subscription_id}" + url = "{0}/{1}".format(self.baseurl, subscription_id) self.delete_request(url) - logger.info(f"Deleted subscription (ID: {subscription_id})") + logger.info("Deleted subscription (ID: {0})".format(subscription_id)) @api(version="2.3") def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem: if not subscription_item.id: error = "Subscription item missing ID. Subscription must be retrieved from server first." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{subscription_item.id}" + url = "{0}/{1}".format(self.baseurl, subscription_item.id) update_req = RequestFactory.Subscription.update_req(subscription_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated subscription item (ID: {subscription_item.id})") + logger.info("Updated subscription item (ID: {0})".format(subscription_item.id)) return SubscriptionItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..36ef78c0a 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,6 +1,5 @@ import logging -from typing import Union -from collections.abc import Iterable +from typing import Iterable, Set, Union from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -16,14 +15,14 @@ class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Tables, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property def baseurl(self): - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" + return "{0}/sites/{1}/tables".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.5") def get(self, req_options=None): @@ -40,8 +39,8 @@ def get_by_id(self, table_id): if not table_id: error = "table ID undefined." raise ValueError(error) - logger.info(f"Querying single table (ID: {table_id})") - url = f"{self.baseurl}/{table_id}" + logger.info("Querying single table (ID: {0})".format(table_id)) + url = "{0}/{1}".format(self.baseurl, table_id) server_response = self.get_request(url) return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -50,9 +49,9 @@ def delete(self, table_id): if not table_id: error = "Database ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{table_id}" + url = "{0}/{1}".format(self.baseurl, table_id) self.delete_request(url) - logger.info(f"Deleted single table (ID: {table_id})") + logger.info("Deleted single table (ID: {0})".format(table_id)) @api(version="3.5") def update(self, table_item): @@ -60,10 +59,10 @@ def update(self, table_item): error = "table item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{table_item.id}" + url = "{0}/{1}".format(self.baseurl, table_item.id) update_req = RequestFactory.Table.update_req(table_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated table item (ID: {table_item.id})") + logger.info("Updated table item (ID: {0})".format(table_item.id)) updated_table = TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] return updated_table @@ -81,10 +80,10 @@ def column_fetcher(): ) table_item._set_columns(column_fetcher) - logger.info(f"Populated columns for table (ID: {table_item.id}") + logger.info("Populated columns for table (ID: {0}".format(table_item.id)) def _get_columns_for_table(self, table_item, req_options=None): - url = f"{self.baseurl}/{table_item.id}/columns" + url = "{0}/{1}/columns".format(self.baseurl, table_item.id) server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -92,12 +91,12 @@ def _get_columns_for_table(self, table_item, req_options=None): @api(version="3.5") def update_column(self, table_item, column_item): - url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" + url = "{0}/{1}/columns/{2}".format(self.baseurl, table_item.id, column_item.id) update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) column = ColumnItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated table item (ID: {table_item.id} & column item {column_item.id}") + logger.info("Updated table item (ID: {0} & column item {1}".format(table_item.id, column_item.id)) return column @api(version="3.5") @@ -129,7 +128,7 @@ def delete_dqw(self, item): self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..a727a515f 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -15,7 +15,7 @@ class Tasks(Endpoint): @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks" + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id) def __normalize_task_type(self, task_type: str) -> str: """ @@ -23,20 +23,20 @@ def __normalize_task_type(self, task_type: str) -> str: It is different than the tag "extractRefresh" used in the request body. """ if task_type == TaskItem.Type.ExtractRefresh: - return f"{task_type}es" + return "{}es".format(task_type) else: return task_type @api(version="2.6") def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh - ) -> tuple[list[TaskItem], PaginationItem]: + ) -> Tuple[List[TaskItem], PaginationItem]: if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") logger.info("Querying all %s tasks for the site", task_type) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}" + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -63,7 +63,7 @@ def create(self, extract_item: TaskItem) -> TaskItem: error = "No extract refresh provided" raise ValueError(error) logger.info("Creating an extract refresh %s", extract_item) - url = f"{self.baseurl}/{self.__normalize_task_type(TaskItem.Type.ExtractRefresh)}" + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) create_req = RequestFactory.Task.create_extract_req(extract_item) server_response = self.post_request(url, create_req) return server_response.content @@ -74,7 +74,7 @@ def run(self, task_item: TaskItem) -> bytes: error = "Task item missing ID." raise MissingRequiredFieldError(error) - url = "{}/{}/{}/runNow".format( + url = "{0}/{1}/{2}/runNow".format( self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id, @@ -92,6 +92,6 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> if not task_id: error = "No Task ID provided" raise ValueError(error) - url = f"{self.baseurl}/{self.__normalize_task_type(task_type)}/{task_id}" + url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id) self.delete_request(url) logger.info("Deleted single task (ID: %s)", task_id) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..c4b6418b7 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,6 +1,6 @@ import copy import logging -from typing import Optional +from typing import List, Optional, Tuple from tableauserverclient.server.query import QuerySet @@ -14,75 +14,13 @@ class Users(QuerysetEndpoint[UserItem]): - """ - The user resources for Tableau Server are defined in the UserItem class. - The class corresponds to the user resources you can access using the - Tableau Server REST API. The user methods are based upon the endpoints for - users in the REST API and operate on the UserItem class. Only server and - site administrators can access the user resources. - """ - @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/users" + return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: - """ - Query all users on the site. Request is paginated and returns a subset of users. - By default, the request returns the first 100 users on the site. - - Parameters - ---------- - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - tuple[list[UserItem], PaginationItem] - Returns a tuple with a list of UserItem objects and a PaginationItem object. - - Raises - ------ - ServerResponseError - code: 400006 - summary: Invalid page number - detail: The page number is not an integer, is less than one, or is - greater than the final page number for users at the requested - page size. - - ServerResponseError - code: 400007 - summary: Invalid page size - detail: The page size parameter is not an integer, is less than one. - - ServerResponseError - code: 403014 - summary: Page size limit exceeded - detail: The specified page size is larger than the maximum page size - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not GET. - - Examples - -------- - >>> import tableauserverclient as TSC - >>> tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') - >>> server = TSC.Server('https://SERVERURL') - - >>> with server.auth.sign_in(tableau_auth): - >>> users_page, pagination_item = server.users.get() - >>> print("\nThere are {} user on site: ".format(pagination_item.total_available)) - >>> print([user.name for user in users_page]) - """ + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[UserItem], PaginationItem]: logger.info("Querying all users on site") if req_options is None: @@ -98,253 +36,55 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt # Gets 1 user by id @api(version="2.0") def get_by_id(self, user_id: str) -> UserItem: - """ - Query a single user by ID. - - Parameters - ---------- - user_id : str - The ID of the user to query. - - Returns - ------- - UserItem - The user item that was queried. - - Raises - ------ - ValueError - If the user ID is not specified. - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 403133 - summary: Query user permissions forbidden - detail: The user does not have permissions to query user information - for other users - - ServerResponseError - code: 404002 - summary: User not found - detail: The user ID in the URI doesn't correspond to an existing user. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not GET. - - Examples - -------- - >>> user1 = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - """ if not user_id: error = "User ID undefined." raise ValueError(error) - logger.info(f"Querying single user (ID: {user_id})") - url = f"{self.baseurl}/{user_id}" + logger.info("Querying single user (ID: {0})".format(user_id)) + url = "{0}/{1}".format(self.baseurl, user_id) server_response = self.get_request(url) return UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() # Update user @api(version="2.0") def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: - """ - Modifies information about the specified user. - - If Tableau Server is configured to use local authentication, you can - update the user's name, email address, password, or site role. - - If Tableau Server is configured to use Active Directory - authentication, you can change the user's display name (full name), - email address, and site role. However, if you synchronize the user with - Active Directory, the display name and email address will be - overwritten with the information that's in Active Directory. - - For Tableau Cloud, you can update the site role for a user, but you - cannot update or change a user's password, user name (email address), - or full name. - - Parameters - ---------- - user_item : UserItem - The user item to update. - - password : Optional[str] - The new password for the user. - - Returns - ------- - UserItem - The user item that was updated. - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> user.fullname = 'New Full Name' - >>> updated_user = server.users.update(user) - - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) - url = f"{self.baseurl}/{user_item.id}" + url = "{0}/{1}".format(self.baseurl, user_item.id) update_req = RequestFactory.User.update_req(user_item, password) server_response = self.put_request(url, update_req) - logger.info(f"Updated user item (ID: {user_item.id})") + logger.info("Updated user item (ID: {0})".format(user_item.id)) updated_item = copy.copy(user_item) return updated_item._parse_common_tags(server_response.content, self.parent_srv.namespace) # Delete 1 user by id @api(version="2.0") def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: - """ - Removes a user from the site. You can also specify a user to map the - assets to when you remove the user. - - Parameters - ---------- - user_id : str - The ID of the user to remove. - - map_assets_to : Optional[str] - The ID of the user to map the assets to when you remove the user. - - Returns - ------- - None - - Raises - ------ - ValueError - If the user ID is not specified. - - Examples - -------- - >>> server.users.remove('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - """ if not user_id: error = "User ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{user_id}" + url = "{0}/{1}".format(self.baseurl, user_id) if map_assets_to is not None: url += f"?mapAssetsTo={map_assets_to}" self.delete_request(url) - logger.info(f"Removed single user (ID: {user_id})") + logger.info("Removed single user (ID: {0})".format(user_id)) # Add new user to site @api(version="2.0") def add(self, user_item: UserItem) -> UserItem: - """ - Adds the user to the site. - - To add a new user to the site you need to first create a new user_item - (from UserItem class). When you create a new user, you specify the name - of the user and their site role. For Tableau Cloud, you also specify - the auth_setting attribute in your request. When you add user to - Tableau Cloud, the name of the user must be the email address that is - used to sign in to Tableau Cloud. After you add a user, Tableau Cloud - sends the user an email invitation. The user can click the link in the - invitation to sign in and update their full name and password. - - Parameters - ---------- - user_item : UserItem - The user item to add to the site. - - Returns - ------- - UserItem - The user item that was added to the site with attributes from the - site populated. - - Raises - ------ - ValueError - If the user item is missing a name - - ValueError - If the user item is missing a site role - - ServerResponseError - code: 400000 - summary: Bad Request - detail: The content of the request body is missing or incomplete, or - contains malformed XML. - - ServerResponseError - code: 400003 - summary: Bad Request - detail: The user authentication setting ServerDefault is not - supported for you site. Try again using TableauIDWithMFA instead. - - ServerResponseError - code: 400013 - summary: Invalid site role - detail: The value of the siteRole attribute must be Explorer, - ExplorerCanPublish, SiteAdministratorCreator, - SiteAdministratorExplorer, Unlicensed, or Viewer. - - ServerResponseError - code: 404000 - summary: Site not found - detail: The site ID in the URI doesn't correspond to an existing site. - - ServerResponseError - code: 404002 - summary: User not found - detail: The server is configured to use Active Directory for - authentication, and the username specified in the request body - doesn't match an existing user in Active Directory. - - ServerResponseError - code: 405000 - summary: Invalid request method - detail: Request type was not POST. - - ServerResponseError - code: 409000 - summary: User conflict - detail: The specified user already exists on the site. - - ServerResponseError - code: 409005 - summary: Guest user conflict - detail: The Tableau Server API doesn't allow adding a user with the - guest role to a site. - - - Examples - -------- - >>> import tableauserverclient as TSC - >>> server = TSC.Server('https://SERVERURL') - >>> # Login to the server - - >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) - >>> new_user = server.users.add(new_user) - - """ url = self.baseurl - logger.info(f"Add user {user_item.name}") + logger.info("Add user {}".format(user_item.name)) add_req = RequestFactory.User.add_req(user_item) server_response = self.post_request(url, add_req) logger.info(server_response) new_user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() - logger.info(f"Added new user (ID: {new_user.id})") + logger.info("Added new user (ID: {0})".format(new_user.id)) return new_user # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: list[UserItem]): + def add_all(self, users: List[UserItem]): created = [] failed = [] for user in users: @@ -358,7 +98,7 @@ def add_all(self, users: list[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish @api(version="2.0") - def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + def create_from_file(self, filepath: str) -> Tuple[List[UserItem], List[Tuple[UserItem, ServerResponseError]]]: created = [] failed = [] if not filepath.find("csv"): @@ -382,42 +122,6 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - """ - Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. - - This method retrieves the workbook information for the specified user. - The REST API is designed to return only the information you ask for - explicitly. When you query for all the users, the workbook information - for each user is not included. Use this method to retrieve information - about the workbooks that the user owns or has Read (view) permissions. - The method adds the list of workbooks to the user item object - (user_item.workbooks). - - Parameters - ---------- - user_item : UserItem - The user item to populate workbooks for. - - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> server.users.populate_workbooks(user) - >>> for wb in user.workbooks: - >>> print(wb.name) - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -429,71 +133,20 @@ def wb_pager(): def _get_wbs_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[WorkbookItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/workbooks" + ) -> Tuple[List[WorkbookItem], PaginationItem]: + url = "{0}/{1}/workbooks".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated workbooks for user (ID: {user_item.id})") + logger.info("Populated workbooks for user (ID: {0})".format(user_item.id)) workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return workbook_item, pagination_item def populate_favorites(self, user_item: UserItem) -> None: - """ - Populate the favorites for the user. - - Parameters - ---------- - user_item : UserItem - The user item to populate favorites for. - - Returns - ------- - None - - Examples - -------- - >>> import tableauserverclient as TSC - >>> server = TSC.Server('https://SERVERURL') - >>> # Login to the server - - >>> user = server.users.get_by_id('9f9e9d9c-8b8a-8f8e-7d7c-7b7a6f6d6e6d') - >>> server.users.populate_favorites(user) - >>> for obj_type, items in user.favorites.items(): - >>> print(f"Favorites for {obj_type}:") - >>> for item in items: - >>> print(item.name) - """ self.parent_srv.favorites.get(user_item) # Get groups for user @api(version="3.7") def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: - """ - Populate the groups for the user. - - Parameters - ---------- - user_item : UserItem - The user item to populate groups for. - - req_options : Optional[RequestOptions] - Optional request options to filter and sort the results. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the user item is missing an ID. - - Examples - -------- - >>> server.users.populate_groups(user) - >>> for group in user.groups: - >>> print(group.name) - """ if not user_item.id: error = "User item missing ID." raise MissingRequiredFieldError(error) @@ -508,10 +161,10 @@ def groups_for_user_pager(): def _get_groups_for_user( self, user_item: UserItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[GroupItem], PaginationItem]: - url = f"{self.baseurl}/{user_item.id}/groups" + ) -> Tuple[List[GroupItem], PaginationItem]: + url = "{0}/{1}/groups".format(self.baseurl, user_item.id) server_response = self.get_request(url, req_options) - logger.info(f"Populated groups for user (ID: {user_item.id})") + logger.info("Populated groups for user (ID: {0})".format(user_item.id)) group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..f2ccf658e 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -11,8 +11,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: from tableauserverclient.server.request_options import ( @@ -26,22 +25,22 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): - super().__init__(parent_srv) + super(Views, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @property def siteurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}" + return "{0}/sites/{1}".format(self.parent_srv.baseurl, self.parent_srv.site_id) @property def baseurl(self) -> str: - return f"{self.siteurl}/views" + return "{0}/views".format(self.siteurl) @api(version="2.2") def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False - ) -> tuple[list[ViewItem], PaginationItem]: + ) -> Tuple[List[ViewItem], PaginationItem]: logger.info("Querying all views on site") url = self.baseurl if usage: @@ -56,8 +55,8 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) - logger.info(f"Querying single view (ID: {view_id})") - url = f"{self.baseurl}/{view_id}" + logger.info("Querying single view (ID: {0})".format(view_id)) + url = "{0}/{1}".format(self.baseurl, view_id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -73,10 +72,10 @@ def image_fetcher(): return self._get_preview_for_view(view_item) view_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for view (ID: {view_item.id})") + logger.info("Populated preview image for view (ID: {0})".format(view_item.id)) def _get_preview_for_view(self, view_item: ViewItem) -> bytes: - url = f"{self.siteurl}/workbooks/{view_item.workbook_id}/views/{view_item.id}/previewImage" + url = "{0}/workbooks/{1}/views/{2}/previewImage".format(self.siteurl, view_item.workbook_id, view_item.id) server_response = self.get_request(url) image = server_response.content return image @@ -91,10 +90,10 @@ def image_fetcher(): return self._get_view_image(view_item, req_options) view_item._set_image(image_fetcher) - logger.info(f"Populated image for view (ID: {view_item.id})") + logger.info("Populated image for view (ID: {0})".format(view_item.id)) def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/image" + url = "{0}/{1}/image".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) image = server_response.content return image @@ -109,10 +108,10 @@ def pdf_fetcher(): return self._get_view_pdf(view_item, req_options) view_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for view (ID: {view_item.id})") + logger.info("Populated pdf for view (ID: {0})".format(view_item.id)) def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: - url = f"{self.baseurl}/{view_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, view_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @@ -127,10 +126,10 @@ def csv_fetcher(): return self._get_view_csv(view_item, req_options) view_item._set_csv(csv_fetcher) - logger.info(f"Populated csv for view (ID: {view_item.id})") + logger.info("Populated csv for view (ID: {0})".format(view_item.id)) def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/data" + url = "{0}/{1}/data".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -145,10 +144,10 @@ def excel_fetcher(): return self._get_view_excel(view_item, req_options) view_item._set_excel(excel_fetcher) - logger.info(f"Populated excel for view (ID: {view_item.id})") + logger.info("Populated excel for view (ID: {0})".format(view_item.id)) def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: - url = f"{self.baseurl}/{view_item.id}/crosstab/excel" + url = "{0}/{1}/crosstab/excel".format(self.baseurl, view_item.id) with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @@ -177,7 +176,7 @@ def update(self, view_item: ViewItem) -> ViewItem: return view_item @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 944b72502..f71db00cc 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,8 +1,7 @@ from functools import partial import json from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem @@ -29,7 +28,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -45,7 +44,7 @@ def _connection_fetcher(): def _get_virtual_database_connections( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[ConnectionItem], PaginationItem]: + ) -> Tuple[List[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -84,7 +83,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None - ) -> tuple[list[RevisionItem], PaginationItem]: + ) -> Tuple[List[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -160,7 +159,7 @@ def delete_permission(self, item, capability_item): @api(version="3.23") def add_tags( self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> set[str]: + ) -> Set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..597f9c425 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Tuple if TYPE_CHECKING: from ..server import Server @@ -15,14 +15,14 @@ class Webhooks(Endpoint): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Webhooks, self).__init__(parent_srv) @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" + return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WebhookItem], PaginationItem]: logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -35,8 +35,8 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - logger.info(f"Querying single webhook (ID: {webhook_id})") - url = f"{self.baseurl}/{webhook_id}" + 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] @@ -45,9 +45,9 @@ def delete(self, webhook_id: str) -> None: if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}" + url = "{0}/{1}".format(self.baseurl, webhook_id) self.delete_request(url) - logger.info(f"Deleted single webhook (ID: {webhook_id})") + logger.info("Deleted single webhook (ID: {0})".format(webhook_id)) @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: @@ -56,7 +56,7 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: server_response = self.post_request(url, create_req) new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Created new webhook (ID: {new_webhook.id})") + logger.info("Created new webhook (ID: {0})".format(new_webhook.id)) return new_webhook @api(version="3.6") @@ -64,7 +64,7 @@ def test(self, webhook_id: str): if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{webhook_id}/test" + url = "{0}/{1}/test".format(self.baseurl, webhook_id) testOutcome = self.get_request(url) - logger.info(f"Testing webhook (ID: {webhook_id} returned {testOutcome})") + logger.info("Testing webhook (ID: {0} returned {1})".format(webhook_id, testOutcome)) return testOutcome diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..da6eda3de 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,7 +7,6 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename -from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in @@ -26,11 +25,15 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, + List, Optional, + Sequence, + Set, + Tuple, TYPE_CHECKING, Union, ) -from collections.abc import Iterable, Sequence if TYPE_CHECKING: from tableauserverclient.server import Server @@ -58,34 +61,18 @@ class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: - super().__init__(parent_srv) + super(Workbooks, self).__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @property def baseurl(self) -> str: - return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/workbooks" + return "{0}/sites/{1}/workbooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: - """ - Queries the server and returns information about the workbooks the site. - - Parameters - ---------- - req_options : RequestOptions, optional - (Optional) You can pass the method a request object that contains - additional parameters to filter the request. For example, if you - were searching for a specific workbook, you could specify the name - of the workbook or the name of the owner. - - Returns - ------- - Tuple containing one page's worth of workbook items and pagination - information. - """ + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[WorkbookItem], PaginationItem]: logger.info("Querying all workbooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -96,44 +83,18 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Work # Get 1 workbook @api(version="2.0") def get_by_id(self, workbook_id: str) -> WorkbookItem: - """ - Returns information about the specified workbook on the site. - - Parameters - ---------- - workbook_id : str - The workbook ID. - - Returns - ------- - WorkbookItem - The workbook item. - """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - logger.info(f"Querying single workbook (ID: {workbook_id})") - url = f"{self.baseurl}/{workbook_id}" + logger.info("Querying single workbook (ID: {0})".format(workbook_id)) + url = "{0}/{1}".format(self.baseurl, workbook_id) server_response = self.get_request(url) return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: - """ - Refreshes the extract of an existing workbook. - - Parameters - ---------- - workbook_item : WorkbookItem | str - The workbook item or workbook ID. - - Returns - ------- - JobItem - The job item. - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/refresh" + url = "{0}/{1}/refresh".format(self.baseurl, id_) empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -146,37 +107,10 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[list["DatasourceItem"]] = None, + datasources: Optional[List["DatasourceItem"]] = None, ) -> JobItem: - """ - Create one or more extracts on 1 workbook, optionally encrypted. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_extracts_for_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to create extracts for. - - encrypt : bool, default False - Set to True to encrypt the extracts. - - includeAll : bool, default True - If True, all data sources in the workbook will have an extract - created for them. If False, then a data source must be supplied in - the request. - - datasources : list[DatasourceItem] | None - List of DatasourceItem objects for the data sources to create - extracts for. Only required if includeAll is False. - - Returns - ------- - JobItem - The job item for the extract creation. - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" + url = "{0}/{1}/createExtract?encrypt={2}".format(self.baseurl, id_, encrypt) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) @@ -186,31 +120,8 @@ def create_extract( # delete all the extracts on 1 workbook @api(version="3.3") def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, datasources=None) -> JobItem: - """ - Delete all extracts of embedded datasources on 1 workbook. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extracts_from_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to delete extracts from. - - includeAll : bool, default True - If True, all data sources in the workbook will have their extracts - deleted. If False, then a data source must be supplied in the - request. - - datasources : list[DatasourceItem] | None - List of DatasourceItem objects for the data sources to delete - extracts from. Only required if includeAll is False. - - Returns - ------- - JobItem - """ id_ = getattr(workbook_item, "id", workbook_item) - url = f"{self.baseurl}/{id_}/deleteExtract" + url = "{0}/{1}/deleteExtract".format(self.baseurl, id_) datasource_req = RequestFactory.Workbook.embedded_extract_req(includeAll, datasources) server_response = self.post_request(url, datasource_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -219,24 +130,12 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d # Delete 1 workbook by id @api(version="2.0") def delete(self, workbook_id: str) -> None: - """ - Deletes a workbook with the specified ID. - - Parameters - ---------- - workbook_id : str - The workbook ID. - - Returns - ------- - None - """ if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) - url = f"{self.baseurl}/{workbook_id}" + url = "{0}/{1}".format(self.baseurl, workbook_id) self.delete_request(url) - logger.info(f"Deleted single workbook (ID: {workbook_id})") + logger.info("Deleted single workbook (ID: {0})".format(workbook_id)) # Update workbook @api(version="2.0") @@ -246,29 +145,6 @@ def update( workbook_item: WorkbookItem, include_view_acceleration_status: bool = False, ) -> WorkbookItem: - """ - Modifies an existing workbook. Use this method to change the owner or - the project that the workbook belongs to, or to change whether the - workbook shows views in tabs. The workbook item must include the - workbook ID and overrides the existing settings. - - See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook - for a list of fields that can be updated. - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to update. ID is required. Other fields are - optional. Any fields that are not specified will not be changed. - - include_view_acceleration_status : bool, default False - Set to True to include the view acceleration status in the response. - - Returns - ------- - WorkbookItem - The updated workbook item. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -276,47 +152,27 @@ def update( self.update_tags(workbook_item) # Update the workbook itself - url = f"{self.baseurl}/{workbook_item.id}" + url = "{0}/{1}".format(self.baseurl, workbook_item.id) if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) - logger.info(f"Updated workbook item (ID: {workbook_item.id})") + logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: - """ - Updates a workbook connection information (server addres, server port, - user name, and password). - - The workbook connections must be populated before the strings can be - updated. - - Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_workbook_connection - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to update. - - connection_item : ConnectionItem - The connection item to update. - - Returns - ------- - ConnectionItem - The updated connection item. - """ - url = f"{self.baseurl}/{workbook_item.id}/connections/{connection_item.id}" + url = "{0}/{1}/connections/{2}".format(self.baseurl, workbook_item.id, connection_item.id) update_req = RequestFactory.Connection.update_req(connection_item) server_response = self.put_request(url, update_req) connection = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") + logger.info( + "Updated workbook item (ID: {0} & connection item {1})".format(workbook_item.id, connection_item.id) + ) return connection # Download workbook contents with option of passing in filepath @@ -329,34 +185,6 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: - """ - Downloads a workbook to the specified directory (optional). - - Parameters - ---------- - workbook_id : str - The workbook ID. - - filepath : Path or File object, optional - Downloads the file to the location you specify. If no location is - specified, the file is downloaded to the current working directory. - The default is Filepath=None. - - include_extract : bool, default True - Set to False to exclude the extract from the download. The default - is True. - - Returns - ------- - Path or File object - The path to the downloaded workbook or the file object. - - Raises - ------ - ValueError - If the workbook ID is not defined. - """ - return self.download_revision( workbook_id, None, @@ -367,48 +195,18 @@ def download( # Get all views of workbook @api(version="2.0") def populate_views(self, workbook_item: WorkbookItem, usage: bool = False) -> None: - """ - Populates (or gets) a list of views for a workbook. - - You must first call this method to populate views before you can iterate - through the views. - - This method retrieves the view information for the specified workbook. - The REST API is designed to return only the information you ask for - explicitly. When you query for all the workbooks, the view information - is not included. Use this method to retrieve the views. The method adds - the list of views to the workbook item (workbook_item.views). This is a - list of ViewItem. - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate views for. - - usage : bool, default False - Set to True to include usage statistics for each view. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - def view_fetcher() -> list[ViewItem]: + def view_fetcher() -> List[ViewItem]: return self._get_views_for_workbook(workbook_item, usage) workbook_item._set_views(view_fetcher) - logger.info(f"Populated views for workbook (ID: {workbook_item.id})") + logger.info("Populated views for workbook (ID: {0})".format(workbook_item.id)) - def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> list[ViewItem]: - url = f"{self.baseurl}/{workbook_item.id}/views" + def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> List[ViewItem]: + url = "{0}/{1}/views".format(self.baseurl, workbook_item.id) if usage: url += "?includeUsageStatistics=true" server_response = self.get_request(url) @@ -422,36 +220,6 @@ def _get_views_for_workbook(self, workbook_item: WorkbookItem, usage: bool) -> l # Get all connections of workbook @api(version="2.0") def populate_connections(self, workbook_item: WorkbookItem) -> None: - """ - Populates a list of data source connections for the specified workbook. - - You must populate connections before you can iterate through the - connections. - - This method retrieves the data source connection information for the - specified workbook. The REST API is designed to return only the - information you ask for explicitly. When you query all the workbooks, - the data source connection information is not included. Use this method - to retrieve the connection information for any data sources used by the - workbook. The method adds the list of data connections to the workbook - item (workbook_item.connections). This is a list of ConnectionItem. - - REST API docs: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_workbook_connections - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate connections for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -460,12 +228,12 @@ def connection_fetcher(): return self._get_workbook_connections(workbook_item) workbook_item._set_connections(connection_fetcher) - logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") + logger.info("Populated connections for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_connections( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[ConnectionItem]: - url = f"{self.baseurl}/{workbook_item.id}/connections" + ) -> List[ConnectionItem]: + url = "{0}/{1}/connections".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections @@ -473,34 +241,6 @@ def _get_workbook_connections( # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: - """ - Populates the PDF for the specified workbook item. - - This method populates a PDF with image(s) of the workbook view(s) you - specify. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_pdf - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the PDF for. - - req_options : RequestOptions, optional - (Optional) You can pass in request options to specify the page type - and orientation of the PDF content, as well as the maximum age of - the PDF rendered on the server. See PDFRequestOptions class for more - details. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -509,46 +249,16 @@ def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) workbook_item._set_pdf(pdf_fetcher) - logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") + logger.info("Populated pdf for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/pdf" + url = "{0}/{1}/pdf".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: - """ - Populates the PowerPoint for the specified workbook item. - - This method populates a PowerPoint with image(s) of the workbook view(s) you - specify. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#download_workbook_powerpoint - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the PDF for. - - req_options : RequestOptions, optional - (Optional) You can pass in request options to specify the maximum - number of minutes a workbook .pptx will be cached before being - refreshed. To prevent multiple .pptx requests from overloading the - server, the shortest interval you can set is one minute. There is no - maximum value, but the server job enacting the caching action may - expire before a long cache period is reached. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID." raise MissingRequiredFieldError(error) @@ -557,10 +267,10 @@ def pptx_fetcher() -> bytes: return self._get_wb_pptx(workbook_item, req_options) workbook_item._set_powerpoint(pptx_fetcher) - logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") + logger.info("Populated powerpoint for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/powerpoint" + url = "{0}/{1}/powerpoint".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) pptx = server_response.content return pptx @@ -568,26 +278,6 @@ def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["Reque # Get preview image of workbook @api(version="2.0") def populate_preview_image(self, workbook_item: WorkbookItem) -> None: - """ - This method gets the preview image (thumbnail) for the specified workbook item. - - This method uses the workbook's ID to get the preview image. The method - adds the preview image to the workbook item (workbook_item.preview_image). - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate the preview image for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -596,75 +286,24 @@ def image_fetcher() -> bytes: return self._get_wb_preview_image(workbook_item) workbook_item._set_preview_image(image_fetcher) - logger.info(f"Populated preview image for workbook (ID: {workbook_item.id})") + logger.info("Populated preview image for workbook (ID: {0})".format(workbook_item.id)) def _get_wb_preview_image(self, workbook_item: WorkbookItem) -> bytes: - url = f"{self.baseurl}/{workbook_item.id}/previewImage" + url = "{0}/{1}/previewImage".format(self.baseurl, workbook_item.id) server_response = self.get_request(url) preview_image = server_response.content return preview_image @api(version="2.0") def populate_permissions(self, item: WorkbookItem) -> None: - """ - Populates the permissions for the specified workbook item. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_workbook_permissions - - Parameters - ---------- - item : WorkbookItem - The workbook item to populate permissions for. - - Returns - ------- - None - """ self._permissions.populate(item) @api(version="2.0") - def update_permissions(self, resource: WorkbookItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: - """ - Updates the permissions for the specified workbook item. The method - replaces the existing permissions with the new permissions. Any missing - permissions are removed. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content - - Parameters - ---------- - resource : WorkbookItem - The workbook item to update permissions for. - - rules : list[PermissionsRule] - A list of permissions rules to apply to the workbook item. - - Returns - ------- - list[PermissionsRule] - The updated permissions rules. - """ + def update_permissions(self, resource, rules): return self._permissions.update(resource, rules) @api(version="2.0") - def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule) -> None: - """ - Deletes a single permission rule from the specified workbook item. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_workbook_permission - - Parameters - ---------- - item : WorkbookItem - The workbook item to delete the permission from. - - capability_item : PermissionsRule - The permission rule to delete. - - Returns - ------- - None - """ + def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) @api(version="2.0") @@ -680,87 +319,10 @@ def publish( skip_connection_check: bool = False, parameters=None, ): - """ - Publish a workbook to the specified site. - - Note: The REST API cannot automatically include extracts or other - resources that the workbook uses. Therefore, a .twb file that uses data - from an Excel or csv file on a local computer cannot be published, - unless you package the data and workbook in a .twbx file, or publish the - data source separately. - - For workbooks that are larger than 64 MB, the publish method - automatically takes care of chunking the file in parts for uploading. - Using this method is considerably more convenient than calling the - publish REST APIs directly. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#publish_workbook - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook_item specifies the workbook you are publishing. When - you are adding a workbook, you need to first create a new instance - of a workbook_item that includes a project_id of an existing - project. The name of the workbook will be the name of the file, - unless you also specify a name for the new workbook when you create - the instance. - - file : Path or File object - The file path or file object of the workbook to publish. When - providing a file object, you must also specifiy the name of the - workbook in your instance of the workbook_itemworkbook_item , as - the name cannot be derived from the file name. - - mode : str - Specifies whether you are publishing a new workbook (CreateNew) or - overwriting an existing workbook (Overwrite). You cannot appending - workbooks. You can also use the publish mode attributes, for - example: TSC.Server.PublishMode.Overwrite. - - connections : list[ConnectionItem] | None - List of ConnectionItems objects for the connections created within - the workbook. - - as_job : bool, default False - Set to True to run the upload as a job (asynchronous upload). If set - to True a job will start to perform the publishing process and a Job - object is returned. Defaults to False. - - skip_connection_check : bool, default False - Set to True to skip connection check at time of upload. Publishing - will succeed but unchecked connection issues may result in a - non-functioning workbook. Defaults to False. - - Raises - ------ - OSError - If the file path does not lead to an existing file. - - ServerResponseError - If the server response is not successful. - - TypeError - If the file is not a file path or file object. - - ValueError - If the file extension is not supported - - ValueError - If the mode is invalid. - - ValueError - Workbooks cannot be appended. - - Returns - ------- - WorkbookItem | JobItem - The workbook item or job item that was published. - """ if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." - raise OSError(error) + raise IOError(error) filename = os.path.basename(file) file_extension = os.path.splitext(filename)[1][1:] @@ -784,12 +346,12 @@ def publish( elif file_type == "xml": file_extension = "twb" else: - error = f"Unsupported file type {file_type}!" + error = "Unsupported file type {}!".format(file_type) raise ValueError(error) # Generate filename for file object. # This is needed when publishing the workbook in a single request - filename = f"{workbook_item.name}.{file_extension}" + filename = "{}.{}".format(workbook_item.name, file_extension) file_size = get_file_object_size(file) else: @@ -800,30 +362,30 @@ def publish( raise ValueError(error) # Construct the url with the defined mode - url = f"{self.baseurl}?workbookType={file_extension}" + url = "{0}?workbookType={1}".format(self.baseurl, file_extension) if mode == self.parent_srv.PublishMode.Overwrite: - url += f"&{mode.lower()}=true" + url += "&{0}=true".format(mode.lower()) elif mode == self.parent_srv.PublishMode.Append: error = "Workbooks cannot be appended." raise ValueError(error) if as_job: - url += "&{}=true".format("asJob") + url += "&{0}=true".format("asJob") if skip_connection_check: - url += "&{}=true".format("skipConnectionCheck") + url += "&{0}=true".format("skipConnectionCheck") # Determine if chunking is required (64MB is the limit for single upload method) if file_size >= FILESIZE_LIMIT: - logger.info(f"Publishing {workbook_item.name} to server with chunking method (workbook over 64MB)") + logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = self.parent_srv.fileuploads.upload(file) - url = f"{url}&uploadSessionId={upload_session_id}" + url = "{0}&uploadSessionId={1}".format(url, upload_session_id) xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, connections=connections, ) else: - logger.info(f"Publishing {filename} to server") + logger.info("Publishing {0} to server".format(filename)) if isinstance(file, (str, Path)): with open(file, "rb") as f: @@ -841,7 +403,7 @@ def publish( file_contents, connections=connections, ) - logger.debug(f"Request xml: {redact_xml(xml_request[:1000])} ") + logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) # Send the publishing request to server try: @@ -853,38 +415,16 @@ def publish( if as_job: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (JOB_ID: {new_job.id}") + logger.info("Published {0} (JOB_ID: {1}".format(workbook_item.name, new_job.id)) return new_job else: new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] - logger.info(f"Published {workbook_item.name} (ID: {new_workbook.id})") + logger.info("Published {0} (ID: {1})".format(workbook_item.name, new_workbook.id)) return new_workbook # Populate workbook item's revisions @api(version="2.3") def populate_revisions(self, workbook_item: WorkbookItem) -> None: - """ - Populates (or gets) a list of revisions for a workbook. - - You must first call this method to populate revisions before you can - iterate through the revisions. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_workbook_revisions - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item to populate revisions for. - - Returns - ------- - None - - Raises - ------ - MissingRequiredFieldError - If the workbook item is missing an ID. - """ if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -893,12 +433,12 @@ def revisions_fetcher(): return self._get_workbook_revisions(workbook_item) workbook_item._set_revisions(revisions_fetcher) - logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") + logger.info("Populated revisions for workbook (ID: {0})".format(workbook_item.id)) def _get_workbook_revisions( self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None - ) -> list[RevisionItem]: - url = f"{self.baseurl}/{workbook_item.id}/revisions" + ) -> List[RevisionItem]: + url = "{0}/{1}/revisions".format(self.baseurl, workbook_item.id) server_response = self.get_request(url, req_options) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions @@ -912,47 +452,13 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: - """ - Downloads a workbook revision to the specified directory (optional). - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_workbook_revision - - Parameters - ---------- - workbook_id : str - The workbook ID. - - revision_number : str | None - The revision number of the workbook. If None, the latest revision is - downloaded. - - filepath : Path or File object, optional - Downloads the file to the location you specify. If no location is - specified, the file is downloaded to the current working directory. - The default is Filepath=None. - - include_extract : bool, default True - Set to False to exclude the extract from the download. The default - is True. - - Returns - ------- - Path or File object - The path to the downloaded workbook or the file object. - - Raises - ------ - ValueError - If the workbook ID is not defined. - """ - if not workbook_id: error = "Workbook ID undefined." raise ValueError(error) if revision_number is None: - url = f"{self.baseurl}/{workbook_id}/content" + url = "{0}/{1}/content".format(self.baseurl, workbook_id) else: - url = f"{self.baseurl}/{workbook_id}/revisions/{revision_number}/content" + url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) if not include_extract: url += "?includeExtract=False" @@ -974,129 +480,37 @@ def download_revision( f.write(chunk) return_path = os.path.abspath(download_path) - logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") + logger.info( + "Downloaded workbook revision {0} to {1} (ID: {2})".format(revision_number, return_path, workbook_id) + ) return return_path @api(version="2.3") def delete_revision(self, workbook_id: str, revision_number: str) -> None: - """ - Deletes a specific revision from a workbook on Tableau Server. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_revisions.htm#remove_workbook_revision - - Parameters - ---------- - workbook_id : str - The workbook ID. - - revision_number : str - The revision number of the workbook to delete. - - Returns - ------- - None - - Raises - ------ - ValueError - If the workbook ID or revision number is not defined. - """ if workbook_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, workbook_id, "revisions", revision_number]) self.delete_request(url) - logger.info(f"Deleted single workbook revision (ID: {workbook_id}) (Revision: {revision_number})") + logger.info("Deleted single workbook revision (ID: {0}) (Revision: {1})".format(workbook_id, revision_number)) # a convenience method @api(version="2.8") def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem - ) -> list["AddResponse"]: # actually should return a task - """ - Adds a workbook to a schedule for extract refresh. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule - - Parameters - ---------- - schedule_id : str - The schedule ID. - - item : WorkbookItem - The workbook item to add to the schedule. - - Returns - ------- - list[AddResponse] - The response from the server. - """ + ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: - """ - Adds tags to a workbook. One or more tags may be added at a time. If a - tag already exists on the workbook, it will not be duplicated. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook - - Parameters - ---------- - item : WorkbookItem | str - The workbook item or workbook ID to add tags to. - - tags : Iterable[str] | str - The tag or tags to add to the workbook. Tags can be a single tag or - a list of tags. - - Returns - ------- - set[str] - The set of tags added to the workbook. - """ + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: - """ - Deletes tags from a workbook. One or more tags may be deleted at a time. - - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tag_from_workbook - - Parameters - ---------- - item : WorkbookItem | str - The workbook item or workbook ID to delete tags from. - - tags : Iterable[str] | str - The tag or tags to delete from the workbook. Tags can be a single - tag or a list of tags. - - Returns - ------- - None - """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: WorkbookItem) -> None: - """ - Updates the tags on a workbook. This method is used to update the tags - on the server to match the tags on the workbook item. This method is a - convenience method that calls add_tags and delete_tags to update the - tags on the server. - - Parameters - ---------- - item : WorkbookItem - The workbook item to update the tags for. The tags on the workbook - item will be used to update the tags on the server. - - Returns - ------- - None - """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index fd90e281f..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,7 +1,7 @@ from .request_options import RequestOptions -class Filter: +class Filter(object): def __init__(self, field, operator, value): self.field = field self.operator = operator @@ -16,7 +16,7 @@ def __str__(self): # to [,] # so effectively, remove any spaces between "," and "'" and then remove all "'" value_string = value_string.replace(", '", ",'").replace("'", "") - return f"{self.field}:{self.operator}:{value_string}" + return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property def value(self): diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..ca9d83872 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,7 +1,6 @@ import copy from functools import partial -from typing import Optional, Protocol, TypeVar, Union, runtime_checkable -from collections.abc import Iterable, Iterator +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -12,12 +11,14 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: + ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: + ... class Pager(Iterable[T]): @@ -26,7 +27,7 @@ class Pager(Iterable[T]): Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models (users in a group, views in a workbook, etc) by passing a different endpoint. - Will loop over anything that returns (list[ModelItem], PaginationItem). + Will loop over anything that returns (List[ModelItem], PaginationItem). """ def __init__( diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..bbca612e9 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,10 +1,8 @@ -from collections.abc import Iterable, Iterator, Sized +from collections.abc import Sized from itertools import count -from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload -import sys +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem -from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.server.sort import Sort @@ -36,36 +34,10 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): - """ - QuerySet is a class that allows easy filtering, sorting, and iterating over - many endpoints in TableauServerClient. It is designed to be used in a similar - way to Django QuerySets, but with a more limited feature set. - - QuerySet is an iterable, and can be used in for loops, list comprehensions, - and other places where iterables are expected. - - QuerySet is also Sized, and can be used in places where the length of the - QuerySet is needed. The length of the QuerySet is the total number of items - available in the QuerySet, not just the number of items that have been - fetched. If the endpoint does not return a total count of items, the length - of the QuerySet will be sys.maxsize. If there is no total count, the - QuerySet will continue to fetch items until there are no more items to - fetch. - - QuerySet is not re-entrant. It is not designed to be used in multiple places - at the same time. If you need to use a QuerySet in multiple places, you - should create a new QuerySet for each place you need to use it, convert it - to a list, or create a deep copy of the QuerySet. - - QuerySets are also indexable, and can be sliced. If you try to access an - index that has not been fetched, the QuerySet will fetch the page that - contains the item you are looking for. - """ - def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) - self._result_cache: list[T] = [] + self._result_cache: List[T] = [] self._pagination_item = PaginationItem() def __iter__(self: Self) -> Iterator[T]: @@ -77,30 +49,19 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page self._result_cache = [] - self._pagination_item._page_number = None - try: - self._fetch_all() - except ServerResponseError as e: - if e.code == "400006": - # If the endpoint does not support pagination, it will end - # up overrunning the total number of pages. Catch the - # error and break out of the loop. - raise StopIteration - if len(self._result_cache) == 0: - return + self._fetch_all() yield from self._result_cache - # If the length of the QuerySet is unknown, continue fetching until - # the result cache is empty. - if (size := len(self)) == 0: - continue - if (page * self.page_size) >= size: + # Set result_cache to empty so the fetch will populate + if (page * self.page_size) >= len(self): return @overload - def __getitem__(self: Self, k: Slice) -> list[T]: ... + def __getitem__(self: Self, k: Slice) -> List[T]: + ... @overload - def __getitem__(self: Self, k: int) -> T: ... + def __getitem__(self: Self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -142,7 +103,6 @@ def __getitem__(self, k): elif k in range(self.total_available): # Otherwise, check if k is even sensible to return self._result_cache = [] - self._pagination_item._page_number = None # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -154,16 +114,11 @@ def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if not self._result_cache and self._pagination_item._page_number is None: - response = self.model.get(self.request_options) - if isinstance(response, tuple): - self._result_cache, self._pagination_item = response - else: - self._result_cache = response - self._pagination_item = PaginationItem() + if not self._result_cache: + self._result_cache, self._pagination_item = self.model.get(self.request_options) def __len__(self: Self) -> int: - return sys.maxsize if self.total_available is None else self.total_available + return self.total_available @property def total_available(self: Self) -> int: @@ -173,16 +128,12 @@ def total_available(self: Self) -> int: @property def page_number(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagenumber from the RequestOptions. - return self._pagination_item.page_number or self.request_options.pagenumber + return self._pagination_item.page_number @property def page_size(self: Self) -> int: self._fetch_all() - # If the PaginationItem is not returned from the endpoint, use the - # pagesize from the RequestOptions. - return self._pagination_item.page_size or self.request_options.pagesize + return self._pagination_item.page_size def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: @@ -209,22 +160,22 @@ def paginate(self: Self, **kwargs) -> Self: return self @staticmethod - def _parse_shorthand_filter(key: str) -> tuple[str, str]: + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals else: operator = tokens[1] if operator not in RequestOptions.Operator.__dict__.values(): - raise ValueError(f"Operator `{operator}` is not valid.") + raise ValueError("Operator `{}` is not valid.".format(operator)) field = to_camel_case(tokens[0]) if field not in RequestOptions.Field.__dict__.values(): - raise ValueError(f"Field name `{field}` is not valid.") + raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) @staticmethod - def _parse_shorthand_sort(key: str) -> tuple[str, str]: + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..96fa14680 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union -from collections.abc import Iterable +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union from typing_extensions import ParamSpec @@ -16,7 +15,7 @@ # this file could be largely replaced if we were willing to import the huge file from generateDS -def _add_multipart(parts: dict) -> tuple[Any, str]: +def _add_multipart(parts: Dict) -> Tuple[Any, str]: mime_multipart_parts = list() for name, (filename, data, content_type) in parts.items(): multipart_part = RequestField(name=name, data=data, filename=filename) @@ -81,7 +80,7 @@ def _add_credentials_element(parent_element, connection_credentials): credentials_element.attrib["oAuth"] = "true" -class AuthRequest: +class AuthRequest(object): def signin_req(self, auth_item): xml_request = ET.Element("tsRequest") @@ -105,7 +104,7 @@ def switch_req(self, site_content_url): return ET.tostring(xml_request) -class ColumnRequest: +class ColumnRequest(object): def update_req(self, column_item): xml_request = ET.Element("tsRequest") column_element = ET.SubElement(xml_request, "column") @@ -116,7 +115,7 @@ def update_req(self, column_item): return ET.tostring(xml_request) -class DataAlertRequest: +class DataAlertRequest(object): def add_user_to_alert(self, alert_item: "DataAlertItem", user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -141,7 +140,7 @@ def update_req(self, alert_item: "DataAlertItem") -> bytes: return ET.tostring(xml_request) -class DatabaseRequest: +class DatabaseRequest(object): def update_req(self, database_item): xml_request = ET.Element("tsRequest") database_element = ET.SubElement(xml_request, "database") @@ -160,7 +159,7 @@ def update_req(self, database_item): return ET.tostring(xml_request) -class DatasourceRequest: +class DatasourceRequest(object): def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials=None, connections=None): xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") @@ -245,7 +244,7 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn return _add_multipart(parts) -class DQWRequest: +class DQWRequest(object): def add_req(self, dqw_item): xml_request = ET.Element("tsRequest") dqw_element = ET.SubElement(xml_request, "dataQualityWarning") @@ -275,7 +274,7 @@ def update_req(self, dqw_item): return ET.tostring(xml_request) -class FavoriteRequest: +class FavoriteRequest(object): def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: """ @@ -330,7 +329,7 @@ def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: return self.add_request(id_, Resource.Workbook, name) -class FileuploadRequest: +class FileuploadRequest(object): def chunk_req(self, chunk): parts = { "request_payload": ("", "", "text/xml"), @@ -339,8 +338,8 @@ def chunk_req(self, chunk): return _add_multipart(parts) -class FlowRequest: - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: +class FlowRequest(object): + def _generate_xml(self, flow_item: "FlowItem", connections: Optional[List["ConnectionItem"]] = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -371,8 +370,8 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[list["ConnectionItem"]] = None, - ) -> tuple[Any, str]: + connections: Optional[List["ConnectionItem"]] = None, + ) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = { @@ -381,14 +380,14 @@ def publish_req( } return _add_multipart(parts) - def publish_req_chunked(self, flow_item, connections=None) -> tuple[Any, str]: + def publish_req_chunked(self, flow_item, connections=None) -> Tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) -class GroupRequest: +class GroupRequest(object): def add_user_req(self, user_id: str) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -478,7 +477,7 @@ def update_req( return ET.tostring(xml_request) -class PermissionRequest: +class PermissionRequest(object): def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: xml_request = ET.Element("tsRequest") permissions_element = ET.SubElement(xml_request, "permissions") @@ -500,7 +499,7 @@ def _add_all_capabilities(self, capabilities_element, capabilities_map): capability_element.attrib["mode"] = mode -class ProjectRequest: +class ProjectRequest(object): def update_req(self, project_item: "ProjectItem") -> bytes: xml_request = ET.Element("tsRequest") project_element = ET.SubElement(xml_request, "project") @@ -531,7 +530,7 @@ def create_req(self, project_item: "ProjectItem") -> bytes: return ET.tostring(xml_request) -class ScheduleRequest: +class ScheduleRequest(object): def create_req(self, schedule_item): xml_request = ET.Element("tsRequest") schedule_element = ET.SubElement(xml_request, "schedule") @@ -610,7 +609,7 @@ def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlo return self._add_to_req(id_, "flow", task_type) -class SiteRequest: +class SiteRequest(object): def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") @@ -849,7 +848,7 @@ def set_versioned_flow_attributes(self, flows_all, flows_edit, flows_schedule, p warnings.warn("In version 3.10 and earlier there is only one option: FlowsEnabled") -class TableRequest: +class TableRequest(object): def update_req(self, table_item): xml_request = ET.Element("tsRequest") table_element = ET.SubElement(xml_request, "table") @@ -872,7 +871,7 @@ def update_req(self, table_item): content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] -class TagRequest: +class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") @@ -882,7 +881,7 @@ def add_req(self, tag_set): return ET.tostring(xml_request) @_tsrequest_wrapped - def batch_create(self, element: ET.Element, tags: set[str], content: content_types) -> bytes: + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: tag_batch = ET.SubElement(element, "tagBatch") tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: @@ -898,7 +897,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ return ET.tostring(element) -class UserRequest: +class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") @@ -932,7 +931,7 @@ def add_req(self, user_item: UserItem) -> bytes: return ET.tostring(xml_request) -class WorkbookRequest: +class WorkbookRequest(object): def _generate_xml( self, workbook_item, @@ -996,9 +995,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib["frequency"] = ( - data_freshness_policy_config.fresh_every_schedule.frequency - ) + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1076,7 +1075,7 @@ def embedded_extract_req( datasource_element.attrib["id"] = id_ -class Connection: +class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") @@ -1099,7 +1098,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["queryTaggingEnabled"] = str(connection_item.query_tagging).lower() -class TaskRequest: +class TaskRequest(object): @_tsrequest_wrapped def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest @@ -1138,7 +1137,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) -class FlowTaskRequest: +class FlowTaskRequest(object): @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") @@ -1172,7 +1171,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) -class SubscriptionRequest: +class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: subscription_element = ET.SubElement(xml_request, "subscription") @@ -1236,13 +1235,13 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt return ET.tostring(xml_request) -class EmptyRequest: +class EmptyRequest(object): @_tsrequest_wrapped def empty_req(self, xml_request: ET.Element) -> None: pass -class WebhookRequest: +class WebhookRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: webhook = ET.SubElement(xml_request, "webhook") @@ -1288,7 +1287,7 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes: return ET.tostring(xml_request) -class CustomViewRequest: +class CustomViewRequest(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): updating_element = ET.SubElement(xml_request, "customView") @@ -1416,7 +1415,7 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) -class RequestFactory: +class RequestFactory(object): Auth = AuthRequest() Connection = Connection() Column = ColumnRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..ddb45834d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,4 @@ import sys -from typing import Optional from typing_extensions import Self @@ -10,12 +9,12 @@ from tableauserverclient.helpers.logging import logger -class RequestOptionsBase: +class RequestOptionsBase(object): # This method is used if server api version is below 3.7 (2020.1) def apply_query_params(self, url): try: params = self.get_query_params() - params_list = [f"{k}={v}" for (k, v) in params.items()] + params_list = ["{}={}".format(k, v) for (k, v) in params.items()] logger.debug("Applying options to request: <%s(%s)>", self.__class__.__name__, ",".join(params_list)) @@ -23,52 +22,15 @@ def apply_query_params(self, url): url, existing_params = url.split("?") params_list.append(existing_params) - return "{}?{}".format(url, "&".join(params_list)) + return "{0}?{1}".format(url, "&".join(params_list)) except NotImplementedError: raise - -# If it wasn't a breaking change, I'd rename it to QueryOptions -""" -This class manages options can be used when querying content on the server -""" + def get_query_params(self): + raise NotImplementedError() class RequestOptions(RequestOptionsBase): - def __init__(self, pagenumber=1, pagesize=None): - self.pagenumber = pagenumber - self.pagesize = pagesize or config.PAGE_SIZE - self.sort = set() - self.filter = set() - # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False - - def get_query_params(self) -> dict: - params = {} - if self.sort and len(self.sort) > 0: - sort_options = (str(sort_item) for sort_item in self.sort) - ordered_sort_options = sorted(sort_options) - params["sort"] = ",".join(ordered_sort_options) - if len(self.filter) > 0: - filter_options = (str(filter_item) for filter_item in self.filter) - ordered_filter_options = sorted(filter_options) - params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: - params["fields"] = "_all_" - if self.pagenumber: - params["pageNumber"] = self.pagenumber - if self.pagesize: - params["pageSize"] = self.pagesize - return params - - def page_size(self, page_size): - self.pagesize = page_size - return self - - def page_number(self, page_number): - self.pagenumber = page_number - return self - class Operator: Equals = "eq" GreaterThan = "gt" @@ -79,7 +41,6 @@ class Operator: Has = "has" CaseInsensitiveEquals = "cieq" - # These are fields in the REST API class Field: Args = "args" AuthenticationType = "authenticationType" @@ -156,53 +117,60 @@ class Direction: Desc = "desc" Asc = "asc" + def __init__(self, pagenumber=1, pagesize=None): + self.pagenumber = pagenumber + self.pagesize = pagesize or config.PAGE_SIZE + self.sort = set() + self.filter = set() -""" -These options can be used by methods that are fetching data exported from a specific content item -""" - - -class _DataExportOptions(RequestOptionsBase): - def __init__(self, maxage: int = -1): - super().__init__() - self.view_filters: list[tuple[str, str]] = [] - self.view_parameters: list[tuple[str, str]] = [] - self.max_age: Optional[int] = maxage - """ - This setting will affect the contents of the workbook as they are exported. - Valid language values are tableau-supported languages like de, es, en - If no locale is specified, the default locale for that language will be used - """ - self.language: Optional[str] = None + # This is private until we expand all of our parsers to handle the extra fields + self._all_fields = False - @property - def max_age(self) -> int: - return self._max_age + def page_size(self, page_size): + self.pagesize = page_size + return self - @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): - self._max_age = value + def page_number(self, page_number): + self.pagenumber = page_number + return self def get_query_params(self): params = {} - if self.max_age != -1: - params["maxAge"] = self.max_age - if self.language: - params["language"] = self.language - - self._append_view_filters(params) + if self.pagenumber: + params["pageNumber"] = self.pagenumber + if self.pagesize: + params["pageSize"] = self.pagesize + if len(self.sort) > 0: + sort_options = (str(sort_item) for sort_item in self.sort) + ordered_sort_options = sorted(sort_options) + params["sort"] = ",".join(ordered_sort_options) + if len(self.filter) > 0: + filter_options = (str(filter_item) for filter_item in self.filter) + ordered_filter_options = sorted(filter_options) + params["filter"] = ",".join(ordered_filter_options) + if self._all_fields: + params["fields"] = "_all_" return params + +class _FilterOptionsBase(RequestOptionsBase): + """Provide a basic implementation of adding view filters to the url""" + + def __init__(self): + self.view_filters = [] + self.view_parameters = [] + + def get_query_params(self): + raise NotImplementedError() + def vf(self, name: str, value: str) -> Self: - """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """Apply a filter to the view for a filter that is a normal column + within the view.""" self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: - """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + """Apply a filter based on a parameter within the workbook.""" self.view_parameters.append((name, value)) return self @@ -213,73 +181,82 @@ 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 +class CSVRequestOptions(_FilterOptionsBase): + def __init__(self, maxage=-1): + super(CSVRequestOptions, self).__init__() + self.max_age = maxage @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") + def max_age(self): + return self._max_age - if self.viz_height is not None: - params["vizHeight"] = self.viz_height + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value - if self.viz_width is not None: - params["vizWidth"] = self.viz_width + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) return params -class CSVRequestOptions(_DataExportOptions): - extension = "csv" +class ExcelRequestOptions(_FilterOptionsBase): + def __init__(self, maxage: int = -1) -> None: + super().__init__() + self.max_age = maxage + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value: int) -> None: + self._max_age = value -class ExcelRequestOptions(_DataExportOptions): - extension = "xlsx" + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) + return params -class ImageRequestOptions(_ImagePDFCommonExportOptions): - extension = "png" +class ImageRequestOptions(_FilterOptionsBase): # 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, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + def __init__(self, imageresolution=None, maxage=-1): + super(ImageRequestOptions, self).__init__() self.image_resolution = imageresolution + self.max_age = maxage + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value def get_query_params(self): - params = super().get_query_params() + params = {} if self.image_resolution: params["resolution"] = self.image_resolution + if self.max_age != -1: + params["maxAge"] = self.max_age + self._append_view_filters(params) return params -class PDFRequestOptions(_ImagePDFCommonExportOptions): +class PDFRequestOptions(_FilterOptionsBase): class PageType: A3 = "a3" A4 = "a4" @@ -301,16 +278,61 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) + super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation + self.max_age = maxage + self.viz_height = viz_height + self.viz_width = viz_width + + @property + def max_age(self): + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + + @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() + def get_query_params(self): + params = {} if self.page_type: params["type"] = self.page_type if self.orientation: params["orientation"] = self.orientation + if self.max_age != -1: + params["maxAge"] = self.max_age + + # 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 + + self._append_view_filters(params) + return params diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4eeefcaf9..e563a7138 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -58,64 +58,8 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint -class Server: - """ - In the Tableau REST API, the server (https://MY-SERVER/) is the base or core - of the URI that makes up the various endpoints or methods for accessing - resources on the server (views, workbooks, sites, users, data sources, etc.) - The TSC library provides a Server class that represents the server. You - create a server instance to sign in to the server and to call the various - methods for accessing resources. - - The Server class contains the attributes that represent the server on - Tableau Server. After you create an instance of the Server class, you can - sign in to the server and call methods to access all of the resources on the - server. - - Parameters - ---------- - server_address : str - Specifies the address of the Tableau Server or Tableau Cloud (for - example, https://MY-SERVER/). - - use_server_version : bool - Specifies the version of the REST API to use (for example, '2.5'). When - you use the TSC library to call methods that access Tableau Server, the - version is passed to the endpoint as part of the URI - (https://MY-SERVER/api/2.5/). Each release of Tableau Server supports - specific versions of the REST API. New versions of the REST API are - released with Tableau Server. By default, the value of version is set to - '2.3', which corresponds to Tableau Server 10.0. You can view or set - this value. You might need to set this to a different value, for - example, if you want to access features that are supported by the server - and a later version of the REST API. For more information, see REST API - Versions. - - Examples - -------- - >>> import tableauserverclient as TSC - - >>> # create a instance of server - >>> server = TSC.Server('https://MY-SERVER') - - >>> # sign in, etc. - - >>> # change the REST API version to match the server - >>> server.use_server_version() - - >>> # or change the REST API version to match a specific version - >>> # for example, 2.8 - >>> # server.version = '2.8' - - """ - +class Server(object): class PublishMode: - """ - Enumerates the options that specify what happens when you publish a - workbook or data source. The options are Overwrite, Append, or - CreateNew. - """ - Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" @@ -186,7 +130,7 @@ def validate_connection_settings(self): raise ValueError("Server connection settings not valid", req_ex) def __repr__(self): - return f"" + return "".format(self.baseurl, self.server_info.serverInfo) def add_http_options(self, options_dict: dict): try: @@ -198,7 +142,7 @@ def add_http_options(self, options_dict: dict): # expected errors on invalid input: # 'set' object has no attribute 'keys', 'list' object has no attribute 'keys' # TypeError: cannot convert dictionary update sequence element #0 to a sequence (input is a tuple) - raise ValueError(f"Invalid http options given: {options_dict}") + raise ValueError("Invalid http options given: {}".format(options_dict)) def clear_http_options(self): self._http_options = dict() @@ -232,15 +176,15 @@ def _determine_highest_version(self): old_version = self.version version = self.server_info.get().rest_api_version except ServerInfoEndpointNotFoundError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except EndpointUnavailableError as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = self._get_legacy_version() except Exception as e: - logger.info(f"Could not get version info from server: {e.__class__}{e}") + logger.info("Could not get version info from server: {}{}".format(e.__class__, e)) version = None - logger.info(f"versions: {version}, {old_version}") + logger.info("versions: {}, {}".format(version, old_version)) return version or old_version def use_server_version(self): @@ -257,12 +201,12 @@ def check_at_least_version(self, target: str): def assert_at_least_version(self, comparison: str, reason: str): if not self.check_at_least_version(comparison): - error = f"{reason} is not available in API version {self.version}. Requires {comparison}" + error = "{} is not available in API version {}. Requires {}".format(reason, self.version, comparison) raise EndpointUnavailableError(error) @property def baseurl(self): - return f"{self._server_address}/api/{str(self.version)}" + return "{0}/api/{1}".format(self._server_address, str(self.version)) @property def namespace(self): diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..2d6bc030a 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,7 +1,7 @@ -class Sort: +class Sort(object): def __init__(self, field, direction): self.field = field self.direction = direction def __str__(self): - return f"{self.field}:{self.direction}" + return "{0}:{1}".format(self.field, self.direction) diff --git a/test/_utils.py b/test/_utils.py index b4ee93bc3..8527aaf8c 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,6 +1,5 @@ import os.path import unittest -from xml.etree import ElementTree as ET from contextlib import contextmanager TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -19,19 +18,6 @@ def read_xml_assets(*args): return map(read_xml_asset, args) -def server_response_error_factory(code: str, summary: str, detail: str) -> str: - root = ET.Element("tsResponse") - error = ET.SubElement(root, "error") - error.attrib["code"] = code - - summary_element = ET.SubElement(error, "summary") - summary_element.text = summary - - detail_element = ET.SubElement(error, "detail") - detail_element.text = detail - return ET.tostring(root, encoding="utf-8").decode("utf-8") - - @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/assets/flow_runs_get.xml b/test/assets/flow_runs_get.xml index 489e8ac63..bdce4cdfb 100644 --- a/test/assets/flow_runs_get.xml +++ b/test/assets/flow_runs_get.xml @@ -1,4 +1,5 @@ + - + \ No newline at end of file diff --git a/test/assets/server_info_wrong_site.html b/test/assets/server_info_wrong_site.html deleted file mode 100644 index e92daeb2d..000000000 --- a/test/assets/server_info_wrong_site.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Example website - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ABCDE
12345
23456
34567
45678
56789
- - - \ No newline at end of file diff --git a/test/test_auth.py b/test/test_auth.py index 48100ad88..eaf13481e 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -63,7 +63,7 @@ def test_sign_in_error(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_invalid_token(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -71,7 +71,7 @@ def test_sign_in_invalid_token(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_in_without_auth(self): with open(SIGN_IN_ERROR_XML, "rb") as f: @@ -79,7 +79,7 @@ def test_sign_in_without_auth(self): with requests_mock.mock() as m: m.post(self.baseurl + "/signin", text=response_xml, status_code=401) tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) + self.assertRaises(TSC.NotSignedInError, self.server.auth.sign_in, tableau_auth) def test_sign_out(self): with open(SIGN_IN_XML, "rb") as f: diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 6e863a863..80800c86b 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,8 +18,6 @@ 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" @@ -248,73 +246,3 @@ 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) diff --git a/test/test_dataalert.py b/test/test_dataalert.py index 6f6f1683c..d9e00a9db 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -108,5 +108,5 @@ def test_delete_user_from_alert(self) -> None: alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" with requests_mock.mock() as m: - m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) + m.delete(self.baseurl + "/{0}/users/{1}".format(alert_id, user_id), status_code=204) self.server.data_alerts.delete_user_from_alert(alert_id, user_id) diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -75,7 +75,7 @@ def test_get(self) -> None: self.assertEqual("Sample datasource", all_datasources[1].name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) + self.assertEqual(set(["world", "indicators", "sample"]), all_datasources[1].tags) self.assertEqual("https://page.com", all_datasources[1].webpage_url) self.assertTrue(all_datasources[1].encrypt_extracts) self.assertFalse(all_datasources[1].has_extracts) @@ -110,7 +110,7 @@ def test_get_by_id(self) -> None: self.assertEqual("Sample datasource", single_datasource.name) self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) + self.assertEqual(set(["world", "indicators", "sample"]), single_datasource.tags) self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) def test_update(self) -> None: @@ -488,7 +488,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.tds" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -659,7 +659,7 @@ def test_revisions(self) -> None: response_xml = read_xml_asset(REVISION_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, datasource.id), text=response_xml) self.server.datasources.populate_revisions(datasource) revisions = datasource.revisions @@ -687,7 +687,7 @@ def test_delete_revision(self) -> None: datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, datasource.id)) self.server.datasources.delete_revision(datasource.id, "3") def test_download_revision(self) -> None: diff --git a/test/test_endpoint.py b/test/test_endpoint.py index ff1ef0f72..8635af978 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -54,7 +54,7 @@ def test_get_request_stream(self) -> None: self.assertFalse(response._content_consumed) def test_binary_log_truncated(self): - class FakeResponse: + class FakeResponse(object): headers = {"Content-Type": "application/octet-stream"} content = b"\x1337" * 1000 status_code = 200 diff --git a/test/test_favorites.py b/test/test_favorites.py index 87332d70f..6f0be3b3c 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -28,7 +28,7 @@ def setUp(self): def test_get(self) -> None: response_xml = read_xml_asset(GET_FAVORITES_XML) with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.get(self.user) self.assertIsNotNone(self.user._favorites) self.assertEqual(len(self.user.favorites["workbooks"]), 1) @@ -54,7 +54,7 @@ def test_add_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_workbook(self.user, workbook) def test_add_favorite_view(self) -> None: @@ -63,7 +63,7 @@ def test_add_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_view(self.user, view) def test_add_favorite_datasource(self) -> None: @@ -72,7 +72,7 @@ def test_add_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(self.baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_datasource(self.user, datasource) def test_add_favorite_project(self) -> None: @@ -82,7 +82,7 @@ def test_add_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.put(f"{baseurl}/{self.user.id}", text=response_xml) + m.put("{0}/{1}".format(baseurl, self.user.id), text=response_xml) self.server.favorites.add_favorite_project(self.user, project) def test_delete_favorite_workbook(self) -> None: @@ -90,7 +90,7 @@ def test_delete_favorite_workbook(self) -> None: workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" workbook.name = "Superstore" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") + m.delete("{0}/{1}/workbooks/{2}".format(self.baseurl, self.user.id, workbook.id)) self.server.favorites.delete_favorite_workbook(self.user, workbook) def test_delete_favorite_view(self) -> None: @@ -98,7 +98,7 @@ def test_delete_favorite_view(self) -> None: view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" view._name = "ENDANGERED SAFARI" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") + m.delete("{0}/{1}/views/{2}".format(self.baseurl, self.user.id, view.id)) self.server.favorites.delete_favorite_view(self.user, view) def test_delete_favorite_datasource(self) -> None: @@ -106,7 +106,7 @@ def test_delete_favorite_datasource(self) -> None: datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" datasource.name = "SampleDS" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") + m.delete("{0}/{1}/datasources/{2}".format(self.baseurl, self.user.id, datasource.id)) self.server.favorites.delete_favorite_datasource(self.user, datasource) def test_delete_favorite_project(self) -> None: @@ -115,5 +115,5 @@ def test_delete_favorite_project(self) -> None: project = TSC.ProjectItem("Tableau") project._id = "1d0304cd-3796-429f-b815-7258370b9b74" with requests_mock.mock() as m: - m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") + m.delete("{0}/{1}/projects/{2}".format(baseurl, self.user.id, project.id)) self.server.favorites.delete_favorite_project(self.user, project) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 0f3234d5d..4c8fb0f9f 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -37,7 +37,7 @@ def test_get_file_type_identifies_a_zip_file(self): with BytesIO() as file_object: with ZipFile(file_object, "w") as zf: with BytesIO() as stream: - stream.write(b"This is a zip file") + stream.write("This is a zip file".encode()) zf.writestr("dummy_file", stream.getbuffer()) file_object.seek(0) file_type = get_file_type(file_object) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 9567bc3ad..50a5ef48b 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -33,7 +33,7 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" + self.baseurl = "{}/sites/{}/fileUploads".format(self.server.baseurl, self.server.site_id) def test_read_chunks_file_path(self): file_path = asset("SampleWB.twbx") @@ -57,7 +57,7 @@ def test_upload_chunks_file_path(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_path) self.assertEqual(upload_id, actual) @@ -72,7 +72,7 @@ def test_upload_chunks_file_object(self): append_response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) + m.put("{}/{}".format(self.baseurl, upload_id), text=append_response_xml) actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 8af2540dc..864c0d3cd 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,4 +1,3 @@ -import sys import unittest import requests_mock @@ -6,7 +5,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time, server_response_error_factory +from ._utils import read_xml_asset, mocked_time GET_XML = "flow_runs_get.xml" GET_BY_ID_XML = "flow_runs_get_by_id.xml" @@ -29,8 +28,9 @@ def test_get(self) -> None: response_xml = read_xml_asset(GET_XML) with requests_mock.mock() as m: m.get(self.baseurl, text=response_xml) - all_flow_runs = self.server.flow_runs.get() + all_flow_runs, pagination_item = self.server.flow_runs.get() + self.assertEqual(2, pagination_item.total_available) self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) @@ -75,7 +75,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) flow_run = self.server.flow_runs.wait_for_job(flow_run_id) self.assertEqual(flow_run_id, flow_run.id) @@ -86,7 +86,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(FlowRunFailedException): self.server.flow_runs.wait_for_job(flow_run_id) @@ -95,17 +95,6 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, flow_run_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) - - def test_queryset(self) -> None: - response_xml = read_xml_asset(GET_XML) - error_response = server_response_error_factory( - "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" - ) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) - m.get(f"{self.baseurl}?pageNumber=2", text=error_response) - queryset = self.server.flow_runs.all() - assert len(queryset) == sys.maxsize diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 2d9f7c7bd..034066e64 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -40,7 +40,7 @@ def test_create_flow_task(self): with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) + m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") self.assertTrue("schedule_id" in create_response_content) diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..fc9c75a6d 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,3 +1,4 @@ +# encoding=utf-8 from pathlib import Path import unittest import os diff --git a/test/test_job.py b/test/test_job.py index 20b238764..d86397086 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -51,7 +51,7 @@ def test_get_by_id(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.get_by_id(job_id) updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) @@ -81,7 +81,7 @@ def test_wait_for_job_finished(self) -> None: response_xml = read_xml_asset(GET_BY_ID_XML) job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) job = self.server.jobs.wait_for_job(job_id) self.assertEqual(job_id, job.id) @@ -92,7 +92,7 @@ def test_wait_for_job_failed(self) -> None: response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(JobFailedException): self.server.jobs.wait_for_job(job_id) @@ -101,7 +101,7 @@ def test_wait_for_job_timeout(self) -> None: response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) + m.get("{0}/{1}".format(self.baseurl, job_id), text=response_xml) with self.assertRaises(TimeoutError): self.server.jobs.wait_for_job(job_id, timeout=30) diff --git a/test/test_pager.py b/test/test_pager.py index 1836095bb..c30352809 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,7 +1,6 @@ import contextlib import os import unittest -import xml.etree.ElementTree as ET import requests_mock @@ -123,14 +122,3 @@ def test_pager_view(self) -> None: m.get(self.server.views.baseurl, text=view_xml) for view in TSC.Pager(self.server.views): assert view.name is not None - - def test_queryset_no_matches(self) -> None: - elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") - ET.SubElement(elem, "pagination", totalAvailable="0") - ET.SubElement(elem, "groups") - xml = ET.tostring(elem).decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.groups.baseurl, text=xml) - all_groups = self.server.groups.all() - groups = list(all_groups) - assert len(groups) == 0 diff --git a/test/test_project.py b/test/test_project.py index 430db84b2..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -241,9 +241,9 @@ def test_delete_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) + endpoint = "{}/permissions/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_permission(item=single_project, rules=rules) def test_delete_workbook_default_permission(self) -> None: @@ -287,19 +287,19 @@ def test_delete_workbook_default_permission(self) -> None: rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) + endpoint = "{}/default-permissions/workbooks/groups/{}".format(single_project._id, single_group._id) + m.delete("{}/{}/Read/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportImage/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportData/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewComments/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/AddComment/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Filter/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ViewUnderlyingData/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ShareView/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/WebAuthoring/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Write/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ExportXml/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangeHierarchy/Allow".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/Delete/Deny".format(self.baseurl, endpoint), status_code=204) + m.delete("{}/{}/ChangePermissions/Allow".format(self.baseurl, endpoint), status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 62e301591..772704f69 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,5 +1,9 @@ import unittest -from unittest import mock + +try: + from unittest import mock +except ImportError: + import mock # type: ignore[no-redef] import tableauserverclient.server.request_factory as factory from tableauserverclient.helpers.strings import redact_xml diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -31,7 +31,7 @@ def setUp(self) -> None: self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" + self.baseurl = "{0}/{1}".format(self.server.sites.baseurl, self.server._site_id) def test_pagination(self) -> None: with open(PAGINATION_XML, "rb") as f: @@ -112,9 +112,9 @@ def test_filter_tags_in(self) -> None: matching_workbooks, pagination_item = self.server.workbooks.get(req_option) self.assertEqual(3, pagination_item.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) # check if filtered projects with spaces & special characters # get correctly returned @@ -148,9 +148,9 @@ def test_filter_tags_in_shorthand(self) -> None: matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) + self.assertEqual(set(["weather"]), matching_workbooks[0].tags) + self.assertEqual(set(["safari"]), matching_workbooks[1].tags) + self.assertEqual(set(["sample"]), matching_workbooks[2].tags) def test_invalid_shorthand_option(self) -> None: with self.assertRaises(ValueError): @@ -358,13 +358,3 @@ def test_queryset_pagesize_filter(self) -> None: queryset = self.server.views.all().filter(page_size=page_size) assert queryset.request_options.pagesize == page_size _ = list(queryset) - - def test_language_export(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.language = "en-US" - - resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search("language=en-us", resp.request.query)) diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..0377295d7 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -106,7 +106,7 @@ def test_get_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -120,7 +120,7 @@ def test_get_hourly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -135,7 +135,7 @@ def test_get_daily_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -150,7 +150,7 @@ def test_get_monthly_by_id(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -165,7 +165,7 @@ def test_get_monthly_by_id_2(self) -> None: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) m.get(baseurl, text=response_xml) schedule = self.server.schedules.get_by_id(schedule_id) self.assertIsNotNone(schedule) @@ -347,7 +347,7 @@ def test_update_after_get(self) -> None: def test_add_workbook(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -362,7 +362,7 @@ def test_add_workbook(self) -> None: def test_add_workbook_with_warnings(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: workbook_response = f.read().decode("utf-8") @@ -378,7 +378,7 @@ def test_add_workbook_with_warnings(self) -> None: def test_add_datasource(self) -> None: self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: datasource_response = f.read().decode("utf-8") @@ -393,7 +393,7 @@ def test_add_datasource(self) -> None: def test_add_flow(self) -> None: self.server.version = "3.3" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" + baseurl = "{}/sites/{}/schedules".format(self.server.baseurl, self.server.site_id) with open(FLOW_GET_BY_ID_XML, "rb") as f: flow_response = f.read().decode("utf-8") diff --git a/test/test_server_info.py b/test/test_server_info.py index fa1472c9a..1cf190ecd 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -4,7 +4,6 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -12,7 +11,6 @@ SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") -SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") class ServerInfoTests(unittest.TestCase): @@ -65,11 +63,3 @@ def test_server_use_server_version_flag(self): m.get("http://test/api/2.4/serverInfo", text=si_response_xml) server = TSC.Server("http://test", use_server_version=True) self.assertEqual(server.version, "2.5") - - def test_server_wrong_site(self): - with open(SERVER_INFO_WRONG_SITE, "rb") as f: - response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.server_info.baseurl, text=response, status_code=404) - with self.assertRaises(NonXMLResponseError): - self.server.server_info.get() diff --git a/test/test_site_model.py b/test/test_site_model.py index 60ad9c5e5..f62eb66f0 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,3 +1,5 @@ +# coding=utf-8 + import unittest import tableauserverclient as TSC diff --git a/test/test_tagging.py b/test/test_tagging.py index 23dffebfb..0184af415 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,6 @@ from contextlib import ExitStack import re -from collections.abc import Iterable +from typing import Iterable import uuid from xml.etree import ElementTree as ET @@ -172,7 +172,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: if isinstance(item, str): stack.enter_context(pytest.raises((ValueError, NotImplementedError))) elif hasattr(item, "_initial_tags"): - initial_tags = {"x", "y", "z"} + initial_tags = set(["x", "y", "z"]) item._initial_tags = initial_tags add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) diff --git a/test/test_task.py b/test/test_task.py index 2d724b879..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -119,7 +119,7 @@ def test_get_materializeviews_tasks(self): with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) + m.get("{}/{}".format(self.server.tasks.baseurl, TaskItem.Type.DataAcceleration), text=response_xml) all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) task = all_tasks[0] @@ -145,7 +145,7 @@ def test_get_by_id(self): response_xml = f.read().decode("utf-8") task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{task_id}", text=response_xml) + m.get("{}/{}".format(self.baseurl, task_id), text=response_xml) task = self.server.tasks.get_by_id(task_id) self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) @@ -159,7 +159,7 @@ def test_run_now(self): with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) + m.post("{}/{}/runNow".format(self.baseurl, task_id), text=response_xml) job_response_content = self.server.tasks.run(task).decode("utf-8") self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) @@ -181,7 +181,7 @@ def test_create_extract_task(self): with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) + m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.tasks.create(task).decode("utf-8") self.assertTrue("task_id" in create_response_content) diff --git a/test/test_user.py b/test/test_user.py index a46624845..1f5eba57f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,5 +1,8 @@ +import io import os import unittest +from typing import List +from unittest.mock import MagicMock import requests_mock @@ -160,7 +163,7 @@ def test_populate_workbooks(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) self.assertEqual("default", workbook_list[0].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + self.assertEqual(set(["Safari", "Sample"]), workbook_list[0].tags) def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") @@ -173,7 +176,7 @@ def test_populate_favorites(self) -> None: with open(GET_FAVORITES_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{baseurl}/{single_user.id}", text=response_xml) + m.get("{0}/{1}".format(baseurl, single_user.id), text=response_xml) self.server.users.populate_favorites(single_user) self.assertIsNotNone(single_user._favorites) self.assertEqual(len(single_user.favorites["workbooks"]), 1) diff --git a/test/test_user_model.py b/test/test_user_model.py index a8a2c51cb..d0997b9ff 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,6 +1,7 @@ import logging import unittest from unittest.mock import * +from typing import List import io import pytest @@ -106,7 +107,7 @@ def test_validate_user_detail_standard(self): TSC.UserItem.CSVImport.create_user_from_line(test_line) # for file handling - def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: + def _mock_file_content(self, content: List[str]) -> io.TextIOWrapper: # the empty string represents EOF # the tests run through the file twice, first to validate then to fetch mock = MagicMock(io.TextIOWrapper) @@ -118,10 +119,10 @@ def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: def test_validate_import_file(self): test_data = self._mock_file_content(UserDataTest.valid_import_content) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, f"Expected two lines to be parsed, got {valid}" - assert invalid == [], f"Expected no failures, got {invalid}" + assert valid == 2, "Expected two lines to be parsed, got {}".format(valid) + assert invalid == [], "Expected no failures, got {}".format(invalid) def test_validate_usernames_file(self): test_data = self._mock_file_content(UserDataTest.usernames) valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" + assert valid == 5, "Exactly 5 of the lines were valid, counted {}".format(valid + invalid) diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -49,7 +49,7 @@ def test_get(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual({"tag1", "tag2"}, all_views[0].tags) + self.assertEqual(set(["tag1", "tag2"]), all_views[0].tags) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) self.assertIsNone(all_views[0].sheet_type) @@ -77,7 +77,7 @@ def test_get_by_id(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) + self.assertEqual(set(["tag1", "tag2"]), view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) @@ -95,7 +95,7 @@ def test_get_by_id_usage(self) -> None: self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) + self.assertEqual(set(["tag1", "tag2"]), view.tags) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 766831b0a..6f94f0c10 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -42,7 +42,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -83,7 +83,7 @@ def test_get(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) self.assertEqual("default", all_workbooks[1].project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) + self.assertEqual(set(["Safari", "Sample"]), all_workbooks[1].tags) def test_get_ignore_invalid_date(self) -> None: with open(GET_INVALID_DATE_XML, "rb") as f: @@ -127,7 +127,7 @@ def test_get_by_id(self) -> None: self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) self.assertEqual("default", single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -152,7 +152,7 @@ def test_get_by_id_personal(self) -> None: self.assertTrue(single_workbook.project_id) self.assertIsNone(single_workbook.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) @@ -277,7 +277,7 @@ def test_download_object(self) -> None: def test_download_sanitizes_name(self) -> None: filename = "Name,With,Commas.twbx" - disposition = f'name="tableau_workbook"; filename="{filename}"' + disposition = 'name="tableau_workbook"; filename="{}"'.format(filename) with requests_mock.mock() as m: m.get( self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", @@ -817,7 +817,7 @@ def test_revisions(self) -> None: with open(REVISION_XML, "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) + m.get("{0}/{1}/revisions".format(self.baseurl, workbook.id), text=response_xml) self.server.workbooks.populate_revisions(workbook) revisions = workbook.revisions @@ -846,7 +846,7 @@ def test_delete_revision(self) -> None: workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") + m.delete("{0}/{1}/revisions/3".format(self.baseurl, workbook.id)) self.server.workbooks.delete_revision(workbook.id, "3") def test_download_revision(self) -> None: diff --git a/versioneer.py b/versioneer.py index cce899f58..86c240e13 100644 --- a/versioneer.py +++ b/versioneer.py @@ -276,6 +276,7 @@ """ +from __future__ import print_function try: import configparser @@ -327,7 +328,7 @@ def get_root(): me_dir = os.path.normcase(os.path.splitext(me)[0]) vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") + print("Warning: build in %s is using versioneer.py from %s" % (os.path.dirname(me), versioneer_py)) except NameError: pass return root @@ -341,7 +342,7 @@ def get_config_from_root(root): # the top of versioneer.py for instructions on writing your setup.cfg . setup_cfg = os.path.join(root, "setup.cfg") parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: + with open(setup_cfg, "r") as f: parser.readfp(f) VCS = parser.get("versioneer", "VCS") # mandatory @@ -397,7 +398,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) ) break - except OSError: + except EnvironmentError: e = sys.exc_info()[1] if e.errno == errno.ENOENT: continue @@ -407,7 +408,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}" + print("unable to find command, tried %s" % (commands,)) return None, None stdout = p.communicate()[0].strip() if sys.version_info[0] >= 3: @@ -422,7 +423,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= LONG_VERSION_PY[ "git" -] = r''' +] = ''' # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build @@ -954,7 +955,7 @@ def git_get_keywords(versionfile_abs): # _version.py. keywords = {} try: - f = open(versionfile_abs) + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -969,7 +970,7 @@ def git_get_keywords(versionfile_abs): if mo: keywords["date"] = mo.group(1) f.close() - except OSError: + except EnvironmentError: pass return keywords @@ -993,11 +994,11 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} + refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -1006,7 +1007,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = set([r for r in refs if re.search(r"\d", r)]) if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -1099,7 +1100,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" + pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % (full_tag, tag_prefix) return pieces pieces["closest-tag"] = full_tag[len(tag_prefix) :] @@ -1144,13 +1145,13 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): files.append(versioneer_file) present = False try: - f = open(".gitattributes") + f = open(".gitattributes", "r") for line in f.readlines(): if line.strip().startswith(versionfile_source): if "export-subst" in line.strip().split()[1:]: present = True f.close() - except OSError: + except EnvironmentError: pass if not present: f = open(".gitattributes", "a+") @@ -1184,7 +1185,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @@ -1211,7 +1212,7 @@ def versions_from_file(filename): try: with open(filename) as f: contents = f.read() - except OSError: + except EnvironmentError: raise NotThisMethod("unable to read _version.py") mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) if not mo: @@ -1228,7 +1229,7 @@ def write_to_version_file(filename, versions): with open(filename, "w") as f: f.write(SHORT_VERSION_PY % contents) - print(f"set {filename} to '{versions['version']}'") + print("set %s to '%s'" % (filename, versions["version"])) def plus_or_dot(pieces): @@ -1451,7 +1452,7 @@ def get_versions(verbose=False): try: ver = versions_from_file(versionfile_abs) if verbose: - print(f"got version from file {versionfile_abs} {ver}") + print("got version from file %s %s" % (versionfile_abs, ver)) return ver except NotThisMethod: pass @@ -1722,7 +1723,7 @@ def do_setup(): root = get_root() try: cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: + except (EnvironmentError, configparser.NoSectionError, configparser.NoOptionError) as e: if isinstance(e, (EnvironmentError, configparser.NoSectionError)): print("Adding sample versioneer config to setup.cfg", file=sys.stderr) with open(os.path.join(root, "setup.cfg"), "a") as f: @@ -1747,9 +1748,9 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") if os.path.exists(ipy): try: - with open(ipy) as f: + with open(ipy, "r") as f: old = f.read() - except OSError: + except EnvironmentError: old = "" if INIT_PY_SNIPPET not in old: print(" appending to %s" % ipy) @@ -1768,12 +1769,12 @@ def do_setup(): manifest_in = os.path.join(root, "MANIFEST.in") simple_includes = set() try: - with open(manifest_in) as f: + with open(manifest_in, "r") as f: for line in f: if line.startswith("include "): for include in line.split()[1:]: simple_includes.add(include) - except OSError: + except EnvironmentError: pass # That doesn't cover everything MANIFEST.in can do # (http://docs.python.org/2/distutils/sourcedist.html#commands), so @@ -1804,7 +1805,7 @@ def scan_setup_py(): found = set() setters = False errors = 0 - with open("setup.py") as f: + with open("setup.py", "r") as f: for line in f.readlines(): if "import versioneer" in line: found.add("import") From 34605289489851184826afd96e8d27982b765ad3 Mon Sep 17 00:00:00 2001 From: LehmD <120600174+LehmD@users.noreply.github.com> Date: Fri, 1 Nov 2024 01:31:23 +0100 Subject: [PATCH 532/567] Workbook update Description (#1516) * Allows workbook updates to change the description starting with api version 3.21 * Fixes formatting * Fixes style issues caused by using black and python version 3.13 --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 460017d1a..53bf0c1a7 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -280,7 +280,7 @@ def update( if include_view_acceleration_status: url += "?includeViewAccelerationStatus=True" - update_req = RequestFactory.Workbook.update_req(workbook_item) + update_req = RequestFactory.Workbook.update_req(workbook_item, self.parent_srv) server_response = self.put_request(url, update_req) logger.info(f"Updated workbook item (ID: {workbook_item.id})") updated_workbook = copy.copy(workbook_item) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f7bd139d7..5849a8dae 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -960,7 +960,7 @@ def _generate_xml( _add_hiddenview_element(views_element, view_name) return ET.tostring(xml_request) - def update_req(self, workbook_item): + def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") if workbook_item.name: @@ -973,6 +973,12 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, "owner") owner_element.attrib["id"] = workbook_item.owner_id + if ( + workbook_item.description is not None + and parent_srv is not None + and parent_srv.check_at_least_version("3.21") + ): + workbook_element.attrib["description"] = workbook_item.description if workbook_item._views is not None: views_element = ET.SubElement(workbook_element, "views") for view in workbook_item.views: From a4278e54382cfe093266ce859d6248f859b7cc34 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:19:47 -0600 Subject: [PATCH 533/567] docs: docstrings for custom_views (#1540) Also adds support for using view_id, workbook_id and owner_id to filter custom_views returned by the REST API Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/custom_view_item.py | 54 ++++- .../server/endpoint/custom_views_endpoint.py | 209 +++++++++++++++++- tableauserverclient/server/request_options.py | 3 + 3 files changed, 249 insertions(+), 17 deletions(-) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index a0c0a9844..5cafe469c 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -5,14 +5,58 @@ from typing import Callable, Optional from collections.abc import Iterator -from .exceptions import UnpopulatedPropertyError -from .user_item import UserItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from ..datetime_helpers import parse_datetime +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.datetime_helpers import parse_datetime class CustomViewItem: + """ + Represents a Custom View item on Tableau Server. + + Parameters + ---------- + id : Optional[str] + The ID of the Custom View item. + + name : Optional[str] + The name of the Custom View item. + + Attributes + ---------- + content_url : Optional[str] + The content URL of the Custom View item. + + created_at : Optional[datetime] + The date and time the Custom View item was created. + + image: bytes + The image of the Custom View item. Must be populated first. + + pdf: bytes + The PDF of the Custom View item. Must be populated first. + + csv: Iterator[bytes] + The CSV of the Custom View item. Must be populated first. + + shared : Optional[bool] + Whether the Custom View item is shared. + + updated_at : Optional[datetime] + The date and time the Custom View item was last updated. + + owner : Optional[UserItem] + The id of the owner of the Custom View item. + + workbook : Optional[WorkbookItem] + The id of the workbook the Custom View item belongs to. + + view : Optional[ViewItem] + The id of the view the Custom View item belongs to. + """ + def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: self._content_url: Optional[str] = None # ? self._created_at: Optional["datetime"] = None diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index b02b05d78..8d78dca7a 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -3,7 +3,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config @@ -21,6 +21,9 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.query import QuerySet + """ Get a list of custom views on a site get the details of a custom view @@ -51,19 +54,31 @@ def baseurl(self) -> str: def expurl(self) -> str: return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" - """ - If the request has no filter parameters: Administrators will see all custom views. - Other users will see only custom views that they own. - If the filter parameters include ownerId: Users will see only custom views that they own. - If the filter parameters include viewId and/or workbookId, and don't include ownerId: - Users will see those custom views that they have Write and WebAuthoring permissions for. - If site user visibility is not set to Limited, the Users will see those custom views that are "public", - meaning the value of their shared attribute is true. - If site user visibility is set to Limited, ???? - """ - @api(version="3.18") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + """ + Get a list of custom views on a site. + + If the request has no filter parameters: Administrators will see all custom views. + Other users will see only custom views that they own. + If the filter parameters include ownerId: Users will see only custom views that they own. + If the filter parameters include viewId and/or workbookId, and don't include ownerId: + Users will see those custom views that they have Write and WebAuthoring permissions for. + If site user visibility is not set to Limited, the Users will see those custom views that are "public", + meaning the value of their shared attribute is true. + If site user visibility is set to Limited, ???? + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_custom_views + + Parameters + ---------- + req_options : RequestOptions, optional + Filtering options for the request, by default None + + Returns + ------- + tuple[list[CustomViewItem], PaginationItem] + """ logger.info("Querying all custom views on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -73,6 +88,19 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Cust @api(version="3.18") def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + """ + Get the details of a specific custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view + + Parameters + ---------- + view_id : str + + Returns + ------- + Optional[CustomViewItem] + """ if not view_id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -83,6 +111,27 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: @api(version="3.18") def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populate the image of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view_image + + Parameters + ---------- + view_item : CustomViewItem + + req_options : ImageRequestOptions, optional + Options to customize the image returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the view_item is missing an ID + """ if not view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +150,26 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag @api(version="3.23") def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populate the PDF of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the PDF for. + + req_options : PDFRequestOptions, optional + Options to customize the PDF returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -121,6 +190,26 @@ def _get_custom_view_pdf( @api(version="3.23") def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populate the CSV of a custom view. + + Parameters + ---------- + custom_view_item : CustomViewItem + The custom view item to populate the CSV for. + + req_options : CSVRequestOptions, optional + Options to customize the CSV returned, by default None + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the custom view item is missing an ID + """ if not custom_view_item.id: error = "Custom View item missing ID." raise MissingRequiredFieldError(error) @@ -141,6 +230,21 @@ def _get_custom_view_csv( @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + """ + Updates the name, owner, or shared status of a custom view. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_custom_view + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to update. + + Returns + ------- + Optional[CustomViewItem] + The updated custom view item. + """ if not view_item.id: error = "Custom view item missing ID." raise MissingRequiredFieldError(error) @@ -158,6 +262,25 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: # Delete 1 view by id @api(version="3.19") def delete(self, view_id: str) -> None: + """ + Deletes a single custom view by ID. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_custom_view + + Parameters + ---------- + view_id : str + The ID of the custom view to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the view_id is not provided. + """ if not view_id: error = "Custom View ID undefined." raise ValueError(error) @@ -167,6 +290,27 @@ def delete(self, view_id: str) -> None: @api(version="3.21") def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + """ + Download the definition of a custom view as json. The file parameter can + be a file path or a file object. If a file path is provided, the file + will be written to that location. If a file object is provided, the file + will be written to that object. + + May contain sensitive information. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to download. + + file : PathOrFileW + The file path or file object to write the custom view to. + + Returns + ------- + PathOrFileW + The file path or file object that the custom view was written to. + """ url = f"{self.expurl}/{view_item.id}/content" server_response = self.get_request(url) if isinstance(file, io_types_w): @@ -180,6 +324,25 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: @api(version="3.21") def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + """ + Publish a custom view to Tableau Server. The file parameter can be a + file path or a file object. If a file path is provided, the file will be + read from that location. If a file object is provided, the file will be + read from that object. + + Parameters + ---------- + view_item : CustomViewItem + The custom view item to publish. + + file : PathOrFileR + The file path or file object to read the custom view from. + + Returns + ------- + Optional[CustomViewItem] + The published custom view item. + """ url = self.expurl if isinstance(file, io_types_r): size = get_file_object_size(file) @@ -207,3 +370,25 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust server_response = self.post_request(url, xml_request, content_type) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]": + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + view_id=... + workbook_id=... + owner_id=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index d79ac7f73..2a5bb805a 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -122,6 +122,7 @@ class Field: NotificationType = "notificationType" OwnerDomain = "ownerDomain" OwnerEmail = "ownerEmail" + OwnerId = "ownerId" OwnerName = "ownerName" ParentProjectId = "parentProjectId" Priority = "priority" @@ -148,8 +149,10 @@ class Field: UpdatedAt = "updatedAt" UserCount = "userCount" UserId = "userId" + ViewId = "viewId" ViewUrlName = "viewUrlName" WorkbookDescription = "workbookDescription" + WorkbookId = "workbookId" WorkbookName = "workbookName" class Direction: From 9826cbf1b36455b1557c297557eabc7f782efe0f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:20:32 -0600 Subject: [PATCH 534/567] feat: capture site content url from sign in (#1524) Sign in attempts will return the site's content url in the response. This change parses that as well and includes it on the server object for later reference by the user. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/auth_endpoint.py | 6 ++++-- tableauserverclient/server/server.py | 11 ++++++++++- test/test_auth.py | 5 +++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 4211bb7ea..35dfa5d78 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -84,9 +84,10 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) @@ -155,9 +156,10 @@ def switch_site(self, site_item: "SiteItem") -> contextmgr: self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) + site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None) auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) - self.parent_srv._set_auth(site_id, user_id, auth_token) + self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") return Auth.contextmgr(self.sign_out) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 4eeefcaf9..02abb3fe3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -207,12 +207,14 @@ def _clear_auth(self): self._site_id = None self._user_id = None self._auth_token = None + self._site_url = None self._session = self._session_factory() - def _set_auth(self, site_id, user_id, auth_token): + def _set_auth(self, site_id, user_id, auth_token, site_url=None): self._site_id = site_id self._user_id = user_id self._auth_token = auth_token + self._site_url = site_url def _get_legacy_version(self): # the serverInfo call was introduced in 2.4, earlier than that we have this different call @@ -282,6 +284,13 @@ def site_id(self): raise NotSignedInError(error) return self._site_id + @property + def site_url(self): + if self._site_url is None: + error = "Missing site URL. You must sign in first." + raise NotSignedInError(error) + return self._site_url + @property def user_id(self): if self._user_id is None: diff --git a/test/test_auth.py b/test/test_auth.py index 48100ad88..09e3e251d 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -27,6 +27,7 @@ def test_sign_in(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_with_personal_access_tokens(self): @@ -41,6 +42,7 @@ def test_sign_in_with_personal_access_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_sign_in_impersonate(self): @@ -93,6 +95,7 @@ def test_sign_out(self): self.assertIsNone(self.server._auth_token) self.assertIsNone(self.server._site_id) + self.assertIsNone(self.server._site_url) self.assertIsNone(self.server._user_id) def test_switch_site(self): @@ -109,6 +112,7 @@ def test_switch_site(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) def test_revoke_all_server_admin_tokens(self): @@ -125,4 +129,5 @@ def test_revoke_all_server_admin_tokens(self): self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) + self.assertEqual("Samples", self.server.site_url) self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) From 9d4e43ee84e6f7b17e973c45d712fae48c9caae9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 27 Nov 2024 00:21:23 -0600 Subject: [PATCH 535/567] fix: datasource id on ConnectionItem (#1538) Closes #1536 Populates the datasource id and name on the `ConnectionItem`s as they return. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/datasources_endpoint.py | 7 ++++++- test/test_datasource.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6bd809c28..88c739dcf 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -102,10 +102,15 @@ def connections_fetcher(): datasource_item._set_connections(connections_fetcher) logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") - def _get_datasource_connections(self, datasource_item, req_options=None): + def _get_datasource_connections( + self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None + ) -> list[ConnectionItem]: url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + for connection in connections: + connection._datasource_id = datasource_item.id + connection._datasource_name = datasource_item.name return connections # Delete 1 datasource by id diff --git a/test/test_datasource.py b/test/test_datasource.py index 45d9ba9c9..e8a95722b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -174,17 +174,22 @@ def test_populate_connections(self) -> None: connections: Optional[list[ConnectionItem]] = single_datasource.connections self.assertIsNotNone(connections) + assert connections is not None ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) self.assertEqual("forty-two.net", ds1.server_address) self.assertEqual("duo", ds1.username) self.assertEqual(True, ds1.embed_password) + self.assertEqual(ds1.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds1.datasource_name) self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) self.assertEqual("sqlserver", ds2.connection_type) self.assertEqual("database.com", ds2.server_address) self.assertEqual("heero", ds2.username) self.assertEqual(False, ds2.embed_password) + self.assertEqual(ds2.datasource_id, single_datasource.id) + self.assertEqual(single_datasource.name, ds2.datasource_name) def test_update_connection(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) From e3f1e22f1e6c37ba2a277b6cad0663c2bb776a34 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:15:16 -0800 Subject: [PATCH 536/567] Adding support for thumbnail related options in workbook publish (#1542) * Adding support for thumbnail related options in workbook publish --- samples/publish_workbook.py | 38 ++++++++++++++---- tableauserverclient/models/workbook_item.py | 27 ++++++++++++- tableauserverclient/server/request_factory.py | 6 +++ test/test_workbook.py | 39 +++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index d31978c0f..052eee1f5 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -36,9 +36,16 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") + group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") + + parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") parser.add_argument("--file", "-f", help="local filepath of the workbook to publish") parser.add_argument("--as-job", "-a", help="Publishing asynchronously", action="store_true") parser.add_argument("--skip-connection-check", "-c", help="Skip live connection check", action="store_true") + parser.add_argument("--project", help="Project within which to publish the workbook") + parser.add_argument("--show-tabs", help="Publish workbooks with tabs displayed", action="store_true") args = parser.parse_args() @@ -50,9 +57,20 @@ def main(): tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Step 2: Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Step2: Retrieve the project id, if a project name was passed + if args.project is not None: + req_options = TSC.RequestOptions() + req_options.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) + ) + projects = list(TSC.Pager(server.projects, req_options)) + if len(projects) > 1: + raise ValueError("The project name is not unique") + project_id = projects[0].id + else: + # Get all the projects on server, then look for the default one. + all_projects, pagination_item = server.projects.get() + project_id = next((project for project in all_projects if project.is_default()), None).id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" @@ -67,10 +85,16 @@ def main(): all_connections.append(connection1) all_connections.append(connection2) - # Step 3: If default project is found, form a new workbook item and publish. + # Step 3: Form a new workbook item and publish. overwrite_true = TSC.Server.PublishMode.Overwrite - if default_project is not None: - new_workbook = TSC.WorkbookItem(default_project.id) + if project_id is not None: + new_workbook = TSC.WorkbookItem( + project_id=project_id, + name=args.workbook_name, + show_tabs=args.show_tabs, + thumbnails_user_id=args.thumbnails_user_id, + thumbnails_group_id=args.thumbnails_group_id, + ) if args.as_job: new_job = server.workbooks.publish( new_workbook, @@ -92,7 +116,7 @@ def main(): ) print(f"Workbook published. ID: {new_workbook.id}") else: - error = "The default project could not be found." + error = "The destination project could not be found." raise LookupError(error) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 776d041e3..32ab413a4 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -99,7 +99,14 @@ class as arguments. The workbook item specifies the project. >>> new_workbook = TSC.WorkbookItem('3a8b6148-493c-11e6-a621-6f3499394a39') """ - def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, show_tabs: bool = False) -> None: + def __init__( + self, + project_id: Optional[str] = None, + name: Optional[str] = None, + show_tabs: bool = False, + thumbnails_user_id: Optional[str] = None, + thumbnails_group_id: Optional[str] = None, + ) -> None: self._connections = None self._content_url = None self._webpage_url = None @@ -130,6 +137,8 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, } self.data_freshness_policy = None self._permissions = None + self.thumbnails_user_id = thumbnails_user_id + self.thumbnails_group_id = thumbnails_group_id return None @@ -275,6 +284,22 @@ def revisions(self) -> list[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def thumbnails_user_id(self) -> Optional[str]: + return self._thumbnails_user_id + + @thumbnails_user_id.setter + def thumbnails_user_id(self, value: str): + self._thumbnails_user_id = value + + @property + def thumbnails_group_id(self) -> Optional[str]: + return self._thumbnails_group_id + + @thumbnails_group_id.setter + def thumbnails_group_id(self, value: str): + self._thumbnails_group_id = value + def _set_connections(self, connections): self._connections = connections diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 5849a8dae..f0b2d1846 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -958,6 +958,12 @@ def _generate_xml( views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: _add_hiddenview_element(views_element, view_name) + + if workbook_item.thumbnails_user_id is not None: + workbook_element.attrib["thumbnailsUserId"] = workbook_item.thumbnails_user_id + elif workbook_item.thumbnails_group_id is not None: + workbook_element.attrib["thumbnailsGroupId"] = workbook_item.thumbnails_group_id + return ET.tostring(xml_request) def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): diff --git a/test/test_workbook.py b/test/test_workbook.py index 1a6b3192f..0aa52f50d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -624,6 +624,45 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) + def test_publish_with_thumbnails_user_id(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761", + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + self.assertTrue(re.search(rb"thumbnailsUserId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\"", request_body)) + + def test_publish_with_thumbnails_group_id(self) -> None: + with open(PUBLISH_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_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762", + ) + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = self.server.PublishMode.CreateNew + new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + self.assertTrue(re.search(rb"thumbnailsGroupId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\"", request_body)) + @pytest.mark.filterwarnings("ignore:'as_job' not available") def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: From bdfecfbf28c2adb91cea5b2539665a5dc84d60cb Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 12 Dec 2024 16:20:40 -0800 Subject: [PATCH 537/567] feat: incremental refresh for extracts (#1545) * implement incremental refresh * add sample that creates an incremental extract/runs 'refresh now' --- samples/create_extract_task.py | 19 +++++--- samples/extracts.py | 46 +++++++++++++++---- samples/publish_workbook.py | 2 +- samples/refresh.py | 33 +++++++++---- .../server/endpoint/datasources_endpoint.py | 6 +-- .../server/endpoint/workbooks_endpoint.py | 8 ++-- tableauserverclient/server/request_factory.py | 7 +++ 7 files changed, 90 insertions(+), 31 deletions(-) diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py index 8408f67ee..8c02fefff 100644 --- a/samples/create_extract_task.py +++ b/samples/create_extract_task.py @@ -29,7 +29,9 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample: - # This sample has no additional options, yet. If you add some, please add them here + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("--incremental", default=False) args = parser.parse_args() @@ -45,6 +47,7 @@ def main(): # Monthly Schedule # This schedule will run on the 15th of every month at 11:30PM monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + print(monthly_interval) monthly_schedule = TSC.ScheduleItem( None, None, @@ -53,18 +56,20 @@ def main(): monthly_interval, ) - # Default to using first workbook found in server - all_workbook_items, pagination_item = server.workbooks.get() - my_workbook: TSC.WorkbookItem = all_workbook_items[0] + my_workbook: TSC.WorkbookItem = server.workbooks.get_by_id(args.resource_id) target_item = TSC.Target( my_workbook.id, # the id of the workbook or datasource "workbook", # alternatively can be "datasource" ) - extract_item = TSC.TaskItem( + refresh_type = "FullRefresh" + if args.incremental: + refresh_type = "Incremental" + + scheduled_extract_item = TSC.TaskItem( None, - "FullRefresh", + refresh_type, None, None, None, @@ -74,7 +79,7 @@ def main(): ) try: - response = server.tasks.create(extract_item) + response = server.tasks.create(scheduled_extract_item) print(response) except Exception as e: print(e) diff --git a/samples/extracts.py b/samples/extracts.py index c0dd885bc..8e7a66aac 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -25,8 +25,11 @@ def main(): help="desired logging level (set to error by default)", ) # Options specific to this sample - parser.add_argument("--delete") - parser.add_argument("--create") + parser.add_argument("--create", action="store_true") + parser.add_argument("--delete", action="store_true") + parser.add_argument("--refresh", action="store_true") + parser.add_argument("--workbook", required=False) + parser.add_argument("--datasource", required=False) args = parser.parse_args() # Set logging level based on user input, or error by default @@ -39,20 +42,45 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() - print(f"\nThere are {pagination_item.total_available} workbooks on site: ") - print([workbook.name for workbook in all_workbooks]) - if all_workbooks: - # Pick one workbook from the list - wb = all_workbooks[3] + wb = None + ds = None + if args.workbook: + wb = server.workbooks.get_by_id(args.workbook) + if wb is None: + raise ValueError(f"Workbook not found for id {args.workbook}") + elif args.datasource: + ds = server.datasources.get_by_id(args.datasource) + if ds is None: + raise ValueError(f"Datasource not found for id {args.datasource}") + else: + # Gets all workbook items + all_workbooks, pagination_item = server.workbooks.get() + print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick one workbook from the list + wb = all_workbooks[3] if args.create: print("create extract on wb ", wb.name) extract_job = server.workbooks.create_extract(wb, includeAll=True) print(extract_job) + if args.refresh: + extract_job = None + if ds is not None: + print(f"refresh extract on datasource {ds.name}") + extract_job = server.datasources.refresh(ds, includeAll=True, incremental=True) + elif wb is not None: + print(f"refresh extract on workbook {wb.name}") + extract_job = server.workbooks.refresh(wb) + else: + print("no content item selected to refresh") + + print(extract_job) + if args.delete: print("delete extract on wb ", wb.name) jj = server.workbooks.delete_extract(wb) diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 052eee1f5..077ddaddd 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -55,7 +55,7 @@ def main(): # Step 1: Sign in to server. tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed if args.project is not None: diff --git a/samples/refresh.py b/samples/refresh.py index d3e49ed24..99242fcdb 100644 --- a/samples/refresh.py +++ b/samples/refresh.py @@ -27,6 +27,8 @@ def main(): # Options specific to this sample parser.add_argument("resource_type", choices=["workbook", "datasource"]) parser.add_argument("resource_id") + parser.add_argument("--incremental") + parser.add_argument("--synchronous") args = parser.parse_args() @@ -34,27 +36,42 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) + refresh_type = "FullRefresh" + incremental = False + if args.incremental: + refresh_type = "Incremental" + incremental = True + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=True) + server = TSC.Server(args.server, use_server_version=True, http_options={"verify": False}) with server.auth.sign_in(tableau_auth): if args.resource_type == "workbook": # Get the workbook by its Id to make sure it exists resource = server.workbooks.get_by_id(args.resource_id) + print(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.workbooks.refresh(args.resource_id) + job = server.workbooks.refresh(args.resource_id, incremental=incremental) else: # Get the datasource by its Id to make sure it exists resource = server.datasources.get_by_id(args.resource_id) + print(resource) + + # server.datasources.create_extract(resource) # trigger the refresh, you'll get a job id back which can be used to poll for when the refresh is done - job = server.datasources.refresh(resource) + job = server.datasources.refresh(resource, incremental=incremental) # by default runs as a sync task, - print(f"Update job posted (ID: {job.id})") - print("Waiting for job...") - # `wait_for_job` will throw if the job isn't executed successfully - job = server.jobs.wait_for_job(job) - print("Job finished succesfully") + print(f"{refresh_type} job posted (ID: {job.id})") + if args.synchronous: + # equivalent to tabcmd --synchnronous: wait for the job to complete + try: + # `wait_for_job` will throw if the job isn't executed successfully + print("Waiting for job...") + server.jobs.wait_for_job(job) + print("Job finished succesfully") + except Exception as e: + print(f"Job failed! {e}") if __name__ == "__main__": diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 88c739dcf..a7a111516 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -187,11 +187,11 @@ def update_connection( return connection @api(version="2.8") - def refresh(self, datasource_item: DatasourceItem) -> JobItem: + def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 53bf0c1a7..4fdcf075b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -118,7 +118,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]) -> JobItem: + def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem: """ Refreshes the extract of an existing workbook. @@ -126,6 +126,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: ---------- workbook_item : WorkbookItem | str The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data Returns ------- @@ -134,8 +136,8 @@ def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem: """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - empty_req = RequestFactory.Empty.empty_req() - server_response = self.post_request(url, empty_req) + refresh_req = RequestFactory.Task.refresh_req(incremental) + server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f0b2d1846..79ac6e4ca 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1117,6 +1117,13 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass + @_tsrequest_wrapped + def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: extract_element = ET.SubElement(xml_request, "extractRefresh") From 9379c40f2113db5f00e90060aa331c314b374485 Mon Sep 17 00:00:00 2001 From: TrimPeachu <77048868+TrimPeachu@users.noreply.github.com> Date: Fri, 13 Dec 2024 04:28:14 +0100 Subject: [PATCH 538/567] Fixing set default permissions for virtual connections (#1535) * Fixing setting default permissions for virtual connections * Adding tests --- tableauserverclient/models/project_item.py | 2 +- ..._virtualconnection_default_permissions.xml | 19 ++++ ..._virtualconnection_default_permissions.xml | 17 +++ test/test_project.py | 107 ++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 test/assets/project_populate_virtualconnection_default_permissions.xml create mode 100644 test/assets/project_update_virtualconnection_default_permissions.xml diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 48f27c60c..b20cb5374 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -174,7 +174,7 @@ def _set_permissions(self, permissions): self._permissions = permissions def _set_default_permissions(self, permissions, content_type): - attr = f"_default_{content_type}_permissions" + attr = f"_default_{content_type}_permissions".lower() setattr( self, attr, diff --git a/test/assets/project_populate_virtualconnection_default_permissions.xml b/test/assets/project_populate_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10678f794 --- /dev/null +++ b/test/assets/project_populate_virtualconnection_default_permissions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_update_virtualconnection_default_permissions.xml b/test/assets/project_update_virtualconnection_default_permissions.xml new file mode 100644 index 000000000..10b5ba6ec --- /dev/null +++ b/test/assets/project_update_virtualconnection_default_permissions.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_project.py b/test/test_project.py index 430db84b2..56787efac 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -16,6 +16,8 @@ POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" +POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_populate_virtualconnection_default_permissions.xml" +UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_update_virtualconnection_default_permissions.xml" class ProjectTests(unittest.TestCase): @@ -303,3 +305,108 @@ def test_delete_workbook_default_permission(self) -> None: m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) + + def test_populate_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + rule = permissions.pop() + + self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + }, + ) + + def test_update_virtualconnection_default_permissions(self): + response_xml = read_xml_asset(UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.put( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + capabilities = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + } + + rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)] + new_rules = self.server.projects.update_virtualconnection_default_permissions(project, rules) + + rule = new_rules.pop() + + self.assertEqual(group.id, rule.grantee.id) + self.assertEqual("group", rule.grantee.tag_name) + self.assertDictEqual( + rule.capabilities, + { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + }, + ) + + def test_delete_virtualconnection_default_permimssions(self): + response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + + self.server.version = "3.23" + base_url = self.server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + self.server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + del_caps = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + } + + rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps) + + endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}" + m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204) + m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) + + self.server.projects.delete_virtualconnection_default_permissions(project, rule) From 7a45224d1dcb045914be16686b7f6b2d742504be Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 12 Dec 2024 21:28:57 -0600 Subject: [PATCH 539/567] docs: webhook docstrings (#1530) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/webhook_item.py | 33 ++++++++ .../server/endpoint/webhooks_endpoint.py | 77 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 98d821fb4..8b551dea4 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -14,6 +14,39 @@ def _parse_event(events): class WebhookItem: + """ + The WebhookItem represents the webhook resources on Tableau Server or + Tableau Cloud. This is the information that can be sent or returned in + response to a REST API request for webhooks. + + Attributes + ---------- + id : Optional[str] + The identifier (luid) for the webhook. You need this value to query a + specific webhook with the get_by_id method or to delete a webhook with + the delete method. + + name : Optional[str] + The name of the webhook. You must specify this when you create an + instance of the WebhookItem. + + url : Optional[str] + The destination URL for the webhook. The webhook destination URL must + be https and have a valid certificate. You must specify this when you + create an instance of the WebhookItem. + + event : Optional[str] + The name of the Tableau event that triggers your webhook.This is either + api-event-name or webhook-source-api-event-name: one of these is + required to create an instance of the WebhookItem. We recommend using + the api-event-name. The event name must be one of the supported events + listed in the Trigger Events table. + https://help.tableau.com/current/developer/webhooks/en-us/docs/webhooks-events-payload.html + + owner_id : Optional[str] + The identifier (luid) of the user who owns the webhook. + """ + def __init__(self): self._id: Optional[str] = None self.name: Optional[str] = None diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 06643f99d..e5c7b5897 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -23,6 +23,21 @@ def baseurl(self) -> str: @api(version="3.6") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + """ + Returns a list of all webhooks on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#list_webhooks_for_site + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filter and sorting options for the request. + + Returns + ------- + tuple[list[WebhookItem], PaginationItem] + A tuple of the list of webhooks and pagination item + """ logger.info("Querying all Webhooks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -32,6 +47,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Webh @api(version="3.6") def get_by_id(self, webhook_id: str) -> WebhookItem: + """ + Returns information about a specified Webhook. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#get_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to query. + + Returns + ------- + WebhookItem + An object containing information about the webhook. + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -42,6 +72,20 @@ def get_by_id(self, webhook_id: str) -> WebhookItem: @api(version="3.6") def delete(self, webhook_id: str) -> None: + """ + Deletes a specified webhook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to delete. + + Returns + ------- + None + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) @@ -51,6 +95,21 @@ def delete(self, webhook_id: str) -> None: @api(version="3.6") def create(self, webhook_item: WebhookItem) -> WebhookItem: + """ + Creates a new webhook on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_webhook + + Parameters + ---------- + webhook_item : WebhookItem + The webhook item to create. + + Returns + ------- + WebhookItem + An object containing information about the created webhook + """ url = self.baseurl create_req = RequestFactory.Webhook.create_req(webhook_item) server_response = self.post_request(url, create_req) @@ -61,6 +120,24 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: @api(version="3.6") def test(self, webhook_id: str): + """ + Tests the specified webhook. Sends an empty payload to the configured + destination URL of the webhook and returns the response from the server. + This is useful for testing, to ensure that things are being sent from + Tableau and received back as expected. + + Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#test_webhook + + Parameters + ---------- + webhook_id : str + The ID of the webhook to test. + + Returns + ------- + XML Response + + """ if not webhook_id: error = "Webhook ID undefined." raise ValueError(error) From 9ba445b00982464e0e0ab6ed9a187127a2942557 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 12 Dec 2024 21:29:31 -0600 Subject: [PATCH 540/567] docs: task docstrings (#1527) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/task_item.py | 30 +++++++ .../server/endpoint/tasks_endpoint.py | 78 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index fa6f782ba..8d2492aed 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -9,6 +9,36 @@ class TaskItem: + """ + Represents a task item in Tableau Server. To create new tasks, see Schedules. + + Parameters + ---------- + id_ : str + The ID of the task. + + task_type : str + Type of task. See TaskItem.Type for possible values. + + priority : int + The priority of the task on the server. + + consecutive_failed_count : int + The number of consecutive times the task has failed. + + schedule_id : str, optional + The ID of the schedule that the task is associated with. + + schedule_item : ScheduleItem, optional + The schedule item that the task is associated with. + + last_run_at : datetime, optional + The last time the task was run. + + target : Target, optional + The target of the task. This can be a workbook or a datasource. + """ + class Type: ExtractRefresh = "extractRefresh" DataAcceleration = "dataAcceleration" diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index eb82c43bc..e1e95041d 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -31,6 +31,24 @@ def __normalize_task_type(self, task_type: str) -> str: def get( self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh ) -> tuple[list[TaskItem], PaginationItem]: + """ + Returns information about tasks on the specified site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#list_extract_refresh_tasks + + Parameters + ---------- + req_options : RequestOptions, optional + Options for the request, such as filtering, sorting, and pagination. + + task_type : str, optional + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + tuple[list[TaskItem], PaginationItem] + + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") @@ -45,6 +63,20 @@ def get( @api(version="2.6") def get_by_id(self, task_id: str) -> TaskItem: + """ + Returns information about the specified task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to query. + + Returns + ------- + TaskItem + """ if not task_id: error = "No Task ID provided" raise ValueError(error) @@ -59,6 +91,21 @@ def get_by_id(self, task_id: str) -> TaskItem: @api(version="3.19") def create(self, extract_item: TaskItem) -> TaskItem: + """ + Creates a custom schedule for an extract refresh on Tableau Cloud. For + Tableau Server, use the Schedules endpoint to create a schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#create_cloud_extract_refresh_task + + Parameters + ---------- + extract_item : TaskItem + The extract refresh task to create. + + Returns + ------- + TaskItem + """ if not extract_item: error = "No extract refresh provided" raise ValueError(error) @@ -70,6 +117,20 @@ def create(self, extract_item: TaskItem) -> TaskItem: @api(version="2.6") def run(self, task_item: TaskItem) -> bytes: + """ + Runs the specified extract refresh task. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + task_item : TaskItem + The task to run. + + Returns + ------- + bytes + """ if not task_item.id: error = "Task item missing ID." raise MissingRequiredFieldError(error) @@ -86,6 +147,23 @@ def run(self, task_item: TaskItem) -> bytes: # Delete 1 task by id @api(version="3.6") def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None: + """ + Deletes the specified extract refresh task on Tableau Server or Tableau Cloud. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#delete_extract_refresh_task + + Parameters + ---------- + task_id : str + The ID of the task to delete. + + task_type : str, default TaskItem.Type.ExtractRefresh + The type of task to query. See TaskItem.Type for possible values. + + Returns + ------- + None + """ if task_type == TaskItem.Type.DataAcceleration: self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks") From 28952e462ac12cb62856218ccb51e9d8de6188cc Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 30 Dec 2024 13:17:39 -0800 Subject: [PATCH 541/567] feat: publish datasource as replacement (#1546) * Add "Replace" to publish type enum --- .../server/endpoint/datasources_endpoint.py | 9 ++++----- tableauserverclient/server/server.py | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index a7a111516..1f00af570 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -260,13 +260,12 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - if not mode or not hasattr(self.parent_srv.PublishMode, mode): - error = "Invalid mode defined." - raise ValueError(error) - # Construct the url with the defined mode url = f"{self.baseurl}?datasourceType={file_extension}" - if mode == self.parent_srv.PublishMode.Overwrite or mode == self.parent_srv.PublishMode.Append: + if not mode or not hasattr(self.parent_srv.PublishMode, mode): + error = f"Invalid mode defined: {mode}" + raise ValueError(error) + else: url += f"&{mode.lower()}=true" if as_job: diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 02abb3fe3..30c635e31 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -119,6 +119,7 @@ class PublishMode: Append = "Append" Overwrite = "Overwrite" CreateNew = "CreateNew" + Replace = "Replace" def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): self._auth_token = None From e373cf4ace9a9980bfabbef4dc5d82c350fdccb3 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 3 Jan 2025 12:03:35 -0800 Subject: [PATCH 542/567] Update versioneer (#1547) * update versioneer, exclude from linter --- pyproject.toml | 13 +- setup.cfg | 10 - setup.py | 9 - tableauserverclient/__init__.py | 6 +- tableauserverclient/{ => bin}/_version.py | 444 +++-- versioneer.py | 1845 --------------------- 6 files changed, 304 insertions(+), 2023 deletions(-) delete mode 100644 setup.cfg rename tableauserverclient/{ => bin}/_version.py (52%) delete mode 100644 versioneer.py diff --git a/pyproject.toml b/pyproject.toml index 08f90c49c..68f7589ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] +requires = ["setuptools>=75.0", "versioneer[toml]==0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -16,7 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.32', # latest as at 7/31/23 'urllib3>=2.2.2,<3', - 'typing_extensions>=4.0.1', + 'typing_extensions>=4.0', ] requires-python = ">=3.9" classifiers = [ @@ -38,6 +38,7 @@ test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytes [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +force-exclude = "tableauserverclient/bin/*" [tool.mypy] check_untyped_defs = false @@ -50,7 +51,15 @@ show_error_codes = true ignore_missing_imports = true # defusedxml library has no types no_implicit_reexport = true implicit_optional = true +exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" + +[tool.versioneer] +VCS = "git" +style = "pep440-pre" +versionfile_source = "tableauserverclient/bin/_version.py" +versionfile_build = "tableauserverclient/bin/_version.py" +tag_prefix = "v" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a551fdb6a..000000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. -# versioneer does not support pyproject.toml -[versioneer] -VCS = git -style = pep440-pre -versionfile_source = tableauserverclient/_version.py -versionfile_build = tableauserverclient/_version.py -tag_prefix = v diff --git a/setup.py b/setup.py index dfd43ae8a..bdce51f2e 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,6 @@ import versioneer from setuptools import setup -""" -once versioneer 0.25 gets released, we can move this from setup.cfg to pyproject.toml -[tool.versioneer] -VCS = "git" -style = "pep440-pre" -versionfile_source = "tableauserverclient/_version.py" -versionfile_build = "tableauserverclient/_version.py" -tag_prefix = "v" -""" setup( version=versioneer.get_version(), cmdclass=versioneer.get_cmdclass(), diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index e0a7abb64..39f8267a8 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,4 +1,4 @@ -from tableauserverclient._version import get_versions +from tableauserverclient.bin._version import get_versions from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( BackgroundJobItem, @@ -133,3 +133,7 @@ "WeeklyInterval", "WorkbookItem", ] + +from .bin import _version + +__version__ = _version.get_versions()["version"] diff --git a/tableauserverclient/_version.py b/tableauserverclient/bin/_version.py similarity index 52% rename from tableauserverclient/_version.py rename to tableauserverclient/bin/_version.py index 79dbed1d8..f23819e86 100644 --- a/tableauserverclient/_version.py +++ b/tableauserverclient/bin/_version.py @@ -1,11 +1,13 @@ + # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -14,9 +16,11 @@ import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -32,14 +36,21 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" - cfg.style = "pep440" + cfg.style = "pep440-pre" cfg.tag_prefix = "v" cfg.parentdir_prefix = "None" cfg.versionfile_source = "tableauserverclient/_version.py" @@ -51,41 +62,50 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} # type: ignore -HANDLERS = {} - +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} HANDLERS[vcs][method] = f return f - return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr else None), - ) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -94,20 +114,22 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env= return None, None else: if verbose: - print(f"unable to find command, tried {commands}") + print("unable to find command, tried %s" % (commands,)) return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -116,61 +138,64 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): """ rootdirs = [] - for i in range(3): + for _ in range(3): dirname = os.path.basename(root) if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level if verbose: - print(f"Tried directories {str(rootdirs)} but none started with prefix {parentdir_prefix}") + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") date = keywords.get("date") if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 # -like" string, which we must then edit to make compliant), because @@ -187,7 +212,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -196,7 +221,7 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} + tags = {r for r in refs if re.search(r'\d', r)} if verbose: print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: @@ -204,30 +229,33 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] + r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } + return {"version": "0+unknown", + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -238,7 +266,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) if rc != 0: if verbose: print("Directory %s not under git control" % root) @@ -246,33 +282,57 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, - [ - "describe", - "--tags", - "--dirty", - "--always", - "--long", - "--match", - "%s*" % tag_prefix, - ], - cwd=root, - ) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -281,16 +341,17 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): dirty = git_describe.endswith("-dirty") pieces["dirty"] = dirty if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] + git_describe = git_describe[:git_describe.rindex("-dirty")] # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) + mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out + # unparsable. Maybe git-describe is misbehaving? + pieces["error"] = ("unable to parse git-describe output: '%s'" + % describe_out) return pieces # tag @@ -299,12 +360,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if verbose: fmt = "tag '%s' doesn't start with prefix '%s'" print(fmt % (full_tag, tag_prefix)) - pieces["error"] = "tag '{}' doesn't start with prefix '{}'".format( - full_tag, - tag_prefix, - ) + pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" + % (full_tag, tag_prefix)) return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] + pieces["closest-tag"] = full_tag[len(tag_prefix):] # distance: number of commits since tag pieces["distance"] = int(mo.group(2)) @@ -315,24 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -350,29 +412,78 @@ def render_pep440(pieces): rendered += ".dirty" else: # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) + rendered = "0+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) if pieces["dirty"]: rendered += ".dirty" return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -399,12 +510,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. + + Exceptions: + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] + """ + if pieces["closest-tag"]: + rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -421,7 +561,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -441,7 +581,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -461,26 +601,28 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } + return {"version": "unknown", + "full-revisionid": pieces.get("long"), + "dirty": None, + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) elif style == "git-describe": @@ -490,16 +632,12 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -510,7 +648,8 @@ def get_versions(): verbose = cfg.verbose try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) + return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, + verbose) except NotThisMethod: pass @@ -519,16 +658,13 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split("/"): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to find root of source tree", + "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -542,10 +678,6 @@ def get_versions(): except NotThisMethod: pass - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } + return {"version": "0+unknown", "full-revisionid": None, + "dirty": None, + "error": "unable to compute version", "date": None} diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index cce899f58..000000000 --- a/versioneer.py +++ /dev/null @@ -1,1845 +0,0 @@ -#!/usr/bin/env python -# Version: 0.18 - -"""The Versioneer - like a rocketeer, but for versions. - -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer -* Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy -* [![Latest Version] -(https://pypip.in/version/versioneer/badge.svg?style=flat) -](https://pypi.python.org/pypi/versioneer/) -* [![Build Status] -(https://travis-ci.org/warner/python-versioneer.png?branch=master) -](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -* `pip install versioneer` to somewhere to your $PATH -* add a `[versioneer]` section to your setup.cfg (see below) -* run `versioneer install` in your source tree, commit the results - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS keyword ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example `git describe --tags --dirty --always` reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the `git archive` command. As a result, generated tarballs will -contain enough information to get the proper version. - -To allow `setup.py` to compute a version too, a `versioneer.py` is added to -the top level of your source tree, next to `setup.py` and the `setup.cfg` -that configures it. This overrides several distutils/setuptools commands to -compute the version when invoked, and changes `setup.py build` and `setup.py -sdist` to replace `_version.py` with a small static file that contains just -the generated version data. - -## Installation - -See [INSTALL.md](./INSTALL.md) for detailed installation instructions. - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -import the top-level `versioneer.py` and run `get_versions()`. - -Both functions return a dictionary with different flavors of version -information: - -* `['version']`: A condensed version string, rendered using the selected - style. This is the most commonly used value for the project's version - string. The default "pep440" style yields strings like `0.11`, - `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section - below for alternative styles. - -* `['full-revisionid']`: detailed revision identifier. For Git, this is the - full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". - -* `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the - commit date in ISO 8601 format. This will be None if the date is not - available. - -* `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that - this is only accurate if run in a VCS checkout, otherwise it is likely to - be False or None - -* `['error']`: if the version string could not be computed, this will be set - to a string describing the problem, otherwise it will be None. It may be - useful to throw an exception in setup.py if this is set, to avoid e.g. - creating tarballs with a version string of "unknown". - -Some variants are more useful than others. Including `full-revisionid` in a -bug report should allow developers to reconstruct the exact code being tested -(or indicate the presence of local changes that should be shared with the -developers). `version` is suitable for display in an "about" box or a CLI -`--version` output: it can be easily compared against release notes and lists -of bugs fixed in various releases. - -The installer adds the following text to your `__init__.py` to place a basic -version in `YOURPROJECT.__version__`: - - from ._version import get_versions - __version__ = get_versions()['version'] - del get_versions - -## Styles - -The setup.cfg `style=` configuration controls how the VCS information is -rendered into a version string. - -The default style, "pep440", produces a PEP440-compliant string, equal to the -un-prefixed tag name for actual releases, and containing an additional "local -version" section with more detail for in-between builds. For Git, this is -TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags ---dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the -tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and -that this commit is two revisions ("+2") beyond the "0.11" tag. For released -software (exactly equal to a known tag), the identifier will only contain the -stripped tag, e.g. "0.11". - -Other styles are available. See [details.md](details.md) in the Versioneer -source tree for descriptions. - -## Debugging - -Versioneer tries to avoid fatal errors: if something goes wrong, it will tend -to return a version of "0+unknown". To investigate the problem, run `setup.py -version`, which will run the version-lookup code in a verbose mode, and will -display the full contents of `get_versions()` (including the `error` string, -which may help identify what went wrong). - -## Known Limitations - -Some situations are known to cause problems for Versioneer. This details the -most significant ones. More can be found on Github -[issues page](https://github.com/warner/python-versioneer/issues). - -### Subprojects - -Versioneer has limited support for source trees in which `setup.py` is not in -the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are -two common reasons why `setup.py` might not be in the root: - -* Source trees which contain multiple subprojects, such as - [Buildbot](https://github.com/buildbot/buildbot), which contains both - "master" and "slave" subprojects, each with their own `setup.py`, - `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI - distributions (and upload multiple independently-installable tarballs). -* Source trees whose main purpose is to contain a C library, but which also - provide bindings to Python (and perhaps other langauges) in subdirectories. - -Versioneer will look for `.git` in parent directories, and most operations -should get the right version string. However `pip` and `setuptools` have bugs -and implementation details which frequently cause `pip install .` from a -subproject directory to fail to find a correct version string (so it usually -defaults to `0+unknown`). - -`pip install --editable .` should work correctly. `setup.py install` might -work too. - -Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in -some later version. - -[Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking -this issue. The discussion in -[PR #61](https://github.com/warner/python-versioneer/pull/61) describes the -issue from the Versioneer side in more detail. -[pip PR#3176](https://github.com/pypa/pip/pull/3176) and -[pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve -pip to let Versioneer work correctly. - -Versioneer-0.16 and earlier only looked for a `.git` directory next to the -`setup.cfg`, so subprojects were completely unsupported with those releases. - -### Editable installs with setuptools <= 18.5 - -`setup.py develop` and `pip install --editable .` allow you to install a -project into a virtualenv once, then continue editing the source code (and -test) without re-installing after every change. - -"Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a -convenient way to specify executable scripts that should be installed along -with the python package. - -These both work as expected when using modern setuptools. When using -setuptools-18.5 or earlier, however, certain operations will cause -`pkg_resources.DistributionNotFound` errors when running the entrypoint -script, which must be resolved by re-installing the package. This happens -when the install happens with one version, then the egg_info data is -regenerated while a different version is checked out. Many setup.py commands -cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into -a different virtualenv), so this can be surprising. - -[Bug #83](https://github.com/warner/python-versioneer/issues/83) describes -this one, but upgrading to a newer version of setuptools should probably -resolve it. - -### Unicode version strings - -While Versioneer works (and is continually tested) with both Python 2 and -Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. -Newer releases probably generate unicode version strings on py2. It's not -clear that this is wrong, but it may be surprising for applications when then -write these strings to a network connection or include them in bytes-oriented -APIs like cryptographic checksums. - -[Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates -this question. - - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* edit `setup.cfg`, if necessary, to include any new configuration settings - indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. -* re-run `versioneer install` in your source tree, to replace - `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - - -## License - -To make Versioneer easier to embed, all its code is dedicated to the public -domain. The `_version.py` that it creates is also in the public domain. -Specifically, both are released under the Creative Commons "Public Domain -Dedication" license (CC0-1.0), as described in -https://creativecommons.org/publicdomain/zero/1.0/ . - -""" - - -try: - import configparser -except ImportError: - import ConfigParser as configparser -import errno -import json -import os -import re -import subprocess -import sys - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_root(): - """Get the project root directory. - - We require that all commands are run from the project root, i.e. the - directory that contains setup.py, setup.cfg, and versioneer.py . - """ - root = os.path.realpath(os.path.abspath(os.getcwd())) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - # allow 'python path/to/setup.py COMMAND' - root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) - setup_py = os.path.join(root, "setup.py") - versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): - err = ( - "Versioneer was unable to run the project root directory. " - "Versioneer requires setup.py to be executed from " - "its immediate directory (like 'python setup.py COMMAND'), " - "or in a way that lets it use sys.argv[0] to find the root " - "(like 'python path/to/setup.py COMMAND')." - ) - raise VersioneerBadRootError(err) - try: - # Certain runtime workflows (setup.py install/develop in a setuptools - # tree) execute all dependencies in a single python process, so - # "versioneer" may be imported multiple times, and python's shared - # module-import table will cache the first one. So we can't use - # os.path.dirname(__file__), as that will find whichever - # versioneer.py was first imported, even in later projects. - me = os.path.realpath(os.path.abspath(__file__)) - me_dir = os.path.normcase(os.path.splitext(me)[0]) - vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) - if me_dir != vsr_dir: - print(f"Warning: build in {os.path.dirname(me)} is using versioneer.py from {versioneer_py}") - except NameError: - pass - return root - - -def get_config_from_root(root): - """Read the project setup.cfg file to determine Versioneer config.""" - # This might raise EnvironmentError (if setup.cfg is missing), or - # configparser.NoSectionError (if it lacks a [versioneer] section), or - # configparser.NoOptionError (if it lacks "VCS="). See the docstring at - # the top of versioneer.py for instructions on writing your setup.cfg . - setup_cfg = os.path.join(root, "setup.cfg") - parser = configparser.SafeConfigParser() - with open(setup_cfg) as f: - parser.readfp(f) - VCS = parser.get("versioneer", "VCS") # mandatory - - def get(parser, name): - if parser.has_option("versioneer", name): - return parser.get("versioneer", name) - return None - - cfg = VersioneerConfig() - cfg.VCS = VCS - cfg.style = get(parser, "style") or "" - cfg.versionfile_source = get(parser, "versionfile_source") - cfg.versionfile_build = get(parser, "versionfile_build") - cfg.tag_prefix = get(parser, "tag_prefix") - if cfg.tag_prefix in ("''", '""'): - cfg.tag_prefix = "" - cfg.parentdir_prefix = get(parser, "parentdir_prefix") - cfg.verbose = get(parser, "verbose") - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -# these dictionaries contain VCS-specific tools -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen( - [c] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None) - ) - break - except OSError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % dispcmd) - print(e) - return None, None - else: - if verbose: - print(f"unable to find command, tried {commands}" - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % dispcmd) - print("stdout was %s" % stdout) - return None, p.returncode - return stdout, p.returncode - - -LONG_VERSION_PY[ - "git" -] = r''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (built by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.18 (https://github.com/warner/python-versioneer) - -"""Git implementation of _version.py.""" - -import errno -import os -import re -import subprocess -import sys - - -def get_keywords(): - """Get the keywords needed to look up the version information.""" - # these strings will be replaced by git during git-archive. - # setup.py/versioneer.py will grep for the variable names, so they must - # each be defined on a line of their own. _version.py will just call - # get_keywords(). - git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" - git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" - keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} - return keywords - - -class VersioneerConfig: - """Container for Versioneer configuration parameters.""" - - -def get_config(): - """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py - cfg = VersioneerConfig() - cfg.VCS = "git" - cfg.style = "%(STYLE)s" - cfg.tag_prefix = "%(TAG_PREFIX)s" - cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" - cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" - cfg.verbose = False - return cfg - - -class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" - - -LONG_VERSION_PY = {} -HANDLERS = {} - - -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): - """Store f in HANDLERS[vcs][method].""" - if vcs not in HANDLERS: - HANDLERS[vcs] = {} - HANDLERS[vcs][method] = f - return f - return decorate - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): - """Call the given command(s).""" - assert isinstance(commands, list) - p = None - for c in commands: - try: - dispcmd = str([c] + args) - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, env=env, - stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% dispcmd) - print(e) - return None, None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None, None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% dispcmd) - print("stdout was %%s" %% stdout) - return None, p.returncode - return stdout, p.returncode - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None, "date": None} - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print("Tried directories %%s but none started with prefix %%s" %% - (str(rootdirs), parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs - tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %%s" %% r) - return {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None, - "date": date} - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return {"version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags", "date": None} - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, - hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %%s not under git control" %% root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%%s*" %% tag_prefix], - cwd=root) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[:git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = ("unable to parse git-describe output: '%%s'" - %% describe_out) - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%%s' doesn't start with prefix '%%s'" - print(fmt %% (full_tag, tag_prefix)) - pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" - %% (full_tag, tag_prefix)) - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix):] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], - cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], - pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%%d" %% pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%%d" %% pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%%s" %% pieces["short"] - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%%s" %% pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%%d" %% pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return {"version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None} - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%%s'" %% style) - - return {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None, - "date": pieces.get("date")} - - -def get_versions(): - """Get version information or return default if unable to do so.""" - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded keywords. - - cfg = get_config() - verbose = cfg.verbose - - try: - return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, - verbose) - except NotThisMethod: - pass - - try: - root = os.path.realpath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): - root = os.path.dirname(root) - except NameError: - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to find root of source tree", - "date": None} - - try: - pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - except NotThisMethod: - pass - - return {"version": "0+unknown", "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", "date": None} -''' - - -@register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): - """Extract version information from the given file.""" - # the code embedded in _version.py can just fetch the value of these - # keywords. When used from setup.py, we don't want to import _version.py, - # so we do it with a regexp instead. This function is not used from - # _version.py. - keywords = {} - try: - f = open(versionfile_abs) - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - if line.strip().startswith("git_date ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["date"] = mo.group(1) - f.close() - except OSError: - pass - return keywords - - -@register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): - """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") - date = keywords.get("date") - if date is not None: - # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant - # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 - # -like" string, which we must then edit to make compliant), because - # it's been around since git-1.5.3, and it's too difficult to - # discover which version we're using, or to work around using an - # older one. - date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - refnames = keywords["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("keywords are unexpanded, not using") - raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = {r.strip() for r in refnames.strip("()").split(",")} - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = {r[len(TAG) :] for r in refs if r.startswith(TAG)} - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = {r for r in refs if re.search(r"\d", r)} - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix) :] - if verbose: - print("picking %s" % r) - return { - "version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": None, - "date": date, - } - # no suitable tags, so version is "0+unknown", but full hex is still there - if verbose: - print("no suitable tags, using unknown + full revision id") - return { - "version": "0+unknown", - "full-revisionid": keywords["full"].strip(), - "dirty": False, - "error": "no suitable tags", - "date": None, - } - - -@register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): - """Get version from 'git describe' in the root of the source tree. - - This only gets called if the git-archive 'subst' keywords were *not* - expanded, and _version.py hasn't already been rewritten with a short - version string, meaning we're inside a checked out source tree. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) - if rc != 0: - if verbose: - print("Directory %s not under git control" % root) - raise NotThisMethod("'git rev-parse --git-dir' returned error") - - # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] - # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = run_command( - GITS, ["describe", "--tags", "--dirty", "--always", "--long", "--match", "%s*" % tag_prefix], cwd=root - ) - # --long was added in git-1.5.5 - if describe_out is None: - raise NotThisMethod("'git describe' failed") - describe_out = describe_out.strip() - full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if full_out is None: - raise NotThisMethod("'git rev-parse' failed") - full_out = full_out.strip() - - pieces = {} - pieces["long"] = full_out - pieces["short"] = full_out[:7] # maybe improved later - pieces["error"] = None - - # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] - # TAG might have hyphens. - git_describe = describe_out - - # look for -dirty suffix - dirty = git_describe.endswith("-dirty") - pieces["dirty"] = dirty - if dirty: - git_describe = git_describe[: git_describe.rindex("-dirty")] - - # now we have TAG-NUM-gHEX or HEX - - if "-" in git_describe: - # TAG-NUM-gHEX - mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) - if not mo: - # unparseable. Maybe git-describe is misbehaving? - pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out - return pieces - - # tag - full_tag = mo.group(1) - if not full_tag.startswith(tag_prefix): - if verbose: - fmt = "tag '%s' doesn't start with prefix '%s'" - print(fmt % (full_tag, tag_prefix)) - pieces["error"] = f"tag '{full_tag}' doesn't start with prefix '{tag_prefix}'" - return pieces - pieces["closest-tag"] = full_tag[len(tag_prefix) :] - - # distance: number of commits since tag - pieces["distance"] = int(mo.group(2)) - - # commit: short hex revision ID - pieces["short"] = mo.group(3) - - else: - # HEX: no tags - pieces["closest-tag"] = None - count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - # commit date: see ISO-8601 comment in git_versions_from_keywords() - date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() - pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) - - return pieces - - -def do_vcs_install(manifest_in, versionfile_source, ipy): - """Git-specific installation logic for Versioneer. - - For Git, this means creating/changing .gitattributes to mark _version.py - for export-subst keyword substitution. - """ - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source] - if ipy: - files.append(ipy) - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os.path.relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - f = open(".gitattributes") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except OSError: - pass - if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - - -def versions_from_parentdir(parentdir_prefix, root, verbose): - """Try to determine the version from the parent directory name. - - Source tarballs conventionally unpack into a directory that includes both - the project name and a version string. We will also support searching up - two directory levels for an appropriately named parent directory - """ - rootdirs = [] - - for i in range(3): - dirname = os.path.basename(root) - if dirname.startswith(parentdir_prefix): - return { - "version": dirname[len(parentdir_prefix) :], - "full-revisionid": None, - "dirty": False, - "error": None, - "date": None, - } - else: - rootdirs.append(root) - root = os.path.dirname(root) # up a level - - if verbose: - print(f"Tried directories {rootdirs!s} but none started with prefix {parentdir_prefix}") - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.18) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -import json - -version_json = ''' -%s -''' # END VERSION_JSON - - -def get_versions(): - return json.loads(version_json) -""" - - -def versions_from_file(filename): - """Try to determine the version from _version.py if present.""" - try: - with open(filename) as f: - contents = f.read() - except OSError: - raise NotThisMethod("unable to read _version.py") - mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S) - if not mo: - raise NotThisMethod("no version_json in _version.py") - return json.loads(mo.group(1)) - - -def write_to_version_file(filename, versions): - """Write the given version number to the given _version.py file.""" - os.unlink(filename) - contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) - with open(filename, "w") as f: - f.write(SHORT_VERSION_PY % contents) - - print(f"set {filename} to '{versions['version']}'") - - -def plus_or_dot(pieces): - """Return a + if we don't already have one, else return a .""" - if "+" in pieces.get("closest-tag", ""): - return "." - return "+" - - -def render_pep440(pieces): - """Build up version string, with post-release "local version identifier". - - Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you - get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty - - Exceptions: - 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += plus_or_dot(pieces) - rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - else: - # exception #1 - rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) - if pieces["dirty"]: - rendered += ".dirty" - return rendered - - -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. - - Exceptions: - 1: no tags. 0.post.devDISTANCE - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] - else: - # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] - return rendered - - -def render_pep440_post(pieces): - """TAG[.postDISTANCE[.dev0]+gHEX] . - - The ".dev0" means dirty. Note that .dev0 sorts backwards - (a dirty tree will appear "older" than the corresponding clean one), - but you shouldn't be releasing software with -dirty anyways. - - Exceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += plus_or_dot(pieces) - rendered += "g%s" % pieces["short"] - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - rendered += "+g%s" % pieces["short"] - return rendered - - -def render_pep440_old(pieces): - """TAG[.postDISTANCE[.dev0]] . - - The ".dev0" means dirty. - - Eexceptions: - 1: no tags. 0.postDISTANCE[.dev0] - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"] or pieces["dirty"]: - rendered += ".post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - else: - # exception #1 - rendered = "0.post%d" % pieces["distance"] - if pieces["dirty"]: - rendered += ".dev0" - return rendered - - -def render_git_describe(pieces): - """TAG[-DISTANCE-gHEX][-dirty]. - - Like 'git describe --tags --dirty --always'. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render_git_describe_long(pieces): - """TAG-DISTANCE-gHEX[-dirty]. - - Like 'git describe --tags --dirty --always -long'. - The distance/hash is unconditional. - - Exceptions: - 1: no tags. HEX[-dirty] (note: no 'g' prefix) - """ - if pieces["closest-tag"]: - rendered = pieces["closest-tag"] - rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) - else: - # exception #1 - rendered = pieces["short"] - if pieces["dirty"]: - rendered += "-dirty" - return rendered - - -def render(pieces, style): - """Render the given version pieces into the requested style.""" - if pieces["error"]: - return { - "version": "unknown", - "full-revisionid": pieces.get("long"), - "dirty": None, - "error": pieces["error"], - "date": None, - } - - if not style or style == "default": - style = "pep440" # the default - - if style == "pep440": - rendered = render_pep440(pieces) - elif style == "pep440-pre": - rendered = render_pep440_pre(pieces) - elif style == "pep440-post": - rendered = render_pep440_post(pieces) - elif style == "pep440-old": - rendered = render_pep440_old(pieces) - elif style == "git-describe": - rendered = render_git_describe(pieces) - elif style == "git-describe-long": - rendered = render_git_describe_long(pieces) - else: - raise ValueError("unknown style '%s'" % style) - - return { - "version": rendered, - "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], - "error": None, - "date": pieces.get("date"), - } - - -class VersioneerBadRootError(Exception): - """The project root directory is unknown or missing key files.""" - - -def get_versions(verbose=False): - """Get the project version from whatever source is available. - - Returns dict with two keys: 'version' and 'full'. - """ - if "versioneer" in sys.modules: - # see the discussion in cmdclass.py:get_cmdclass() - del sys.modules["versioneer"] - - root = get_root() - cfg = get_config_from_root(root) - - assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" - handlers = HANDLERS.get(cfg.VCS) - assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose - assert cfg.versionfile_source is not None, "please set versioneer.versionfile_source" - assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" - - versionfile_abs = os.path.join(root, cfg.versionfile_source) - - # extract version from first of: _version.py, VCS command (e.g. 'git - # describe'), parentdir. This is meant to work for developers using a - # source checkout, for users of a tarball created by 'setup.py sdist', - # and for users of a tarball/zipball created by 'git archive' or github's - # download-from-tag feature or the equivalent in other VCSes. - - get_keywords_f = handlers.get("get_keywords") - from_keywords_f = handlers.get("keywords") - if get_keywords_f and from_keywords_f: - try: - keywords = get_keywords_f(versionfile_abs) - ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) - if verbose: - print("got version from expanded keyword %s" % ver) - return ver - except NotThisMethod: - pass - - try: - ver = versions_from_file(versionfile_abs) - if verbose: - print(f"got version from file {versionfile_abs} {ver}") - return ver - except NotThisMethod: - pass - - from_vcs_f = handlers.get("pieces_from_vcs") - if from_vcs_f: - try: - pieces = from_vcs_f(cfg.tag_prefix, root, verbose) - ver = render(pieces, cfg.style) - if verbose: - print("got version from VCS %s" % ver) - return ver - except NotThisMethod: - pass - - try: - if cfg.parentdir_prefix: - ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) - if verbose: - print("got version from parentdir %s" % ver) - return ver - except NotThisMethod: - pass - - if verbose: - print("unable to compute version") - - return { - "version": "0+unknown", - "full-revisionid": None, - "dirty": None, - "error": "unable to compute version", - "date": None, - } - - -def get_version(): - """Get the short version string for this project.""" - return get_versions()["version"] - - -def get_cmdclass(): - """Get the custom setuptools/distutils subclasses used by Versioneer.""" - if "versioneer" in sys.modules: - del sys.modules["versioneer"] - # this fixes the "python setup.py develop" case (also 'install' and - # 'easy_install .'), in which subdependencies of the main project are - # built (using setup.py bdist_egg) in the same python process. Assume - # a main project A and a dependency B, which use different versions - # of Versioneer. A's setup.py imports A's Versioneer, leaving it in - # sys.modules by the time B's setup.py is executed, causing B to run - # with the wrong versioneer. Setuptools wraps the sub-dep builds in a - # sandbox that restores sys.modules to it's pre-build state, so the - # parent is protected against the child's "import versioneer". By - # removing ourselves from sys.modules here, before the child build - # happens, we protect the child from the parent's versioneer too. - # Also see https://github.com/warner/python-versioneer/issues/52 - - cmds = {} - - # we add "version" to both distutils and setuptools - from distutils.core import Command - - class cmd_version(Command): - description = "report generated version string" - user_options = [] - boolean_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - vers = get_versions(verbose=True) - print("Version: %s" % vers["version"]) - print(" full-revisionid: %s" % vers.get("full-revisionid")) - print(" dirty: %s" % vers.get("dirty")) - print(" date: %s" % vers.get("date")) - if vers["error"]: - print(" error: %s" % vers["error"]) - - cmds["version"] = cmd_version - - # we override "build_py" in both distutils and setuptools - # - # most invocation pathways end up running build_py: - # distutils/build -> build_py - # distutils/install -> distutils/build ->.. - # setuptools/bdist_wheel -> distutils/install ->.. - # setuptools/bdist_egg -> distutils/install_lib -> build_py - # setuptools/install -> bdist_egg ->.. - # setuptools/develop -> ? - # pip install: - # copies source tree to a tempdir before running egg_info/etc - # if .git isn't copied too, 'git describe' will fail - # then does setup.py bdist_wheel, or sometimes setup.py install - # setup.py egg_info -> ? - - # we override different "build_py" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.build_py import build_py as _build_py - else: - from distutils.command.build_py import build_py as _build_py - - class cmd_build_py(_build_py): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - _build_py.run(self) - # now locate _version.py in the new build/ directory and replace - # it with an updated value - if cfg.versionfile_build: - target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - cmds["build_py"] = cmd_build_py - - if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - - # nczeczulin reports that py2exe won't like the pep440-style string - # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. - # setup(console=[{ - # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION - # "product_version": versioneer.get_version(), - # ... - - class cmd_build_exe(_build_exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _build_exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["build_exe"] = cmd_build_exe - del cmds["build_py"] - - if "py2exe" in sys.modules: # py2exe enabled? - try: - from py2exe.distutils_buildexe import py2exe as _py2exe # py3 - except ImportError: - from py2exe.build_exe import py2exe as _py2exe # py2 - - class cmd_py2exe(_py2exe): - def run(self): - root = get_root() - cfg = get_config_from_root(root) - versions = get_versions() - target_versionfile = cfg.versionfile_source - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, versions) - - _py2exe.run(self) - os.unlink(target_versionfile) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - cmds["py2exe"] = cmd_py2exe - - # we override different "sdist" commands for both environments - if "setuptools" in sys.modules: - from setuptools.command.sdist import sdist as _sdist - else: - from distutils.command.sdist import sdist as _sdist - - class cmd_sdist(_sdist): - def run(self): - versions = get_versions() - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old - # version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir, files): - root = get_root() - cfg = get_config_from_root(root) - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory - # (remembering that it may be a hardlink) and replace it with an - # updated value - target_versionfile = os.path.join(base_dir, cfg.versionfile_source) - print("UPDATING %s" % target_versionfile) - write_to_version_file(target_versionfile, self._versioneer_generated_versions) - - cmds["sdist"] = cmd_sdist - - return cmds - - -CONFIG_ERROR = """ -setup.cfg is missing the necessary Versioneer configuration. You need -a section like: - - [versioneer] - VCS = git - style = pep440 - versionfile_source = src/myproject/_version.py - versionfile_build = myproject/_version.py - tag_prefix = - parentdir_prefix = myproject- - -You will also need to edit your setup.py to use the results: - - import versioneer - setup(version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), ...) - -Please read the docstring in ./versioneer.py for configuration instructions, -edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. -""" - -SAMPLE_CONFIG = """ -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. - -[versioneer] -#VCS = git -#style = pep440 -#versionfile_source = -#versionfile_build = -#tag_prefix = -#parentdir_prefix = - -""" - -INIT_PY_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - - -def do_setup(): - """Main VCS-independent setup function for installing Versioneer.""" - root = get_root() - try: - cfg = get_config_from_root(root) - except (OSError, configparser.NoSectionError, configparser.NoOptionError) as e: - if isinstance(e, (EnvironmentError, configparser.NoSectionError)): - print("Adding sample versioneer config to setup.cfg", file=sys.stderr) - with open(os.path.join(root, "setup.cfg"), "a") as f: - f.write(SAMPLE_CONFIG) - print(CONFIG_ERROR, file=sys.stderr) - return 1 - - print(" creating %s" % cfg.versionfile_source) - with open(cfg.versionfile_source, "w") as f: - LONG = LONG_VERSION_PY[cfg.VCS] - f.write( - LONG - % { - "DOLLAR": "$", - "STYLE": cfg.style, - "TAG_PREFIX": cfg.tag_prefix, - "PARENTDIR_PREFIX": cfg.parentdir_prefix, - "VERSIONFILE_SOURCE": cfg.versionfile_source, - } - ) - - ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") - if os.path.exists(ipy): - try: - with open(ipy) as f: - old = f.read() - except OSError: - old = "" - if INIT_PY_SNIPPET not in old: - print(" appending to %s" % ipy) - with open(ipy, "a") as f: - f.write(INIT_PY_SNIPPET) - else: - print(" %s unmodified" % ipy) - else: - print(" %s doesn't exist, ok" % ipy) - ipy = None - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(root, "MANIFEST.in") - simple_includes = set() - try: - with open(manifest_in) as f: - for line in f: - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except OSError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - with open(manifest_in, "a") as f: - f.write("include versioneer.py\n") - else: - print(" 'versioneer.py' already in MANIFEST.in") - if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % cfg.versionfile_source) - with open(manifest_in, "a") as f: - f.write("include %s\n" % cfg.versionfile_source) - else: - print(" versionfile_source already in MANIFEST.in") - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-subst keyword - # substitution. - do_vcs_install(manifest_in, cfg.versionfile_source, ipy) - return 0 - - -def scan_setup_py(): - """Validate the contents of setup.py against Versioneer's expectations.""" - found = set() - setters = False - errors = 0 - with open("setup.py") as f: - for line in f.readlines(): - if "import versioneer" in line: - found.add("import") - if "versioneer.get_cmdclass()" in line: - found.add("cmdclass") - if "versioneer.get_version()" in line: - found.add("get_version") - if "versioneer.VCS" in line: - setters = True - if "versioneer.versionfile_source" in line: - setters = True - if len(found) != 3: - print("") - print("Your setup.py appears to be missing some important items") - print("(but I might be wrong). Please make sure it has something") - print("roughly like the following:") - print("") - print(" import versioneer") - print(" setup( version=versioneer.get_version(),") - print(" cmdclass=versioneer.get_cmdclass(), ...)") - print("") - errors += 1 - if setters: - print("You should remove lines like 'versioneer.VCS = ' and") - print("'versioneer.versionfile_source = ' . This configuration") - print("now lives in setup.cfg, and should be removed from setup.py") - print("") - errors += 1 - return errors - - -if __name__ == "__main__": - cmd = sys.argv[1] - if cmd == "setup": - errors = do_setup() - errors += scan_setup_py() - if errors: - sys.exit(1) From b9c36c1d7e2d4828f427656eee87c55a871e8e0f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 14:05:59 -0600 Subject: [PATCH 543/567] docs: docstrings for Pager and RequestOptions (#1498) * docs: docstrings for filter tooling * docs: docstring for Sort --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/pager.py | 24 ++++ tableauserverclient/server/request_options.py | 125 +++++++++++++++++- tableauserverclient/server/sort.py | 14 ++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index e6d261b61..3c7e60f74 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -27,6 +27,30 @@ class Pager(Iterable[T]): (users in a group, views in a workbook, etc) by passing a different endpoint. Will loop over anything that returns (list[ModelItem], PaginationItem). + + Will make a copy of the `RequestOptions` object passed in so it can be reused. + + Makes a call to the Server for each page of items, then yields each item in the list. + + Parameters + ---------- + endpoint: CallableEndpoint[T] or Endpoint[T] + The endpoint to call to get the items. Can be a callable or an Endpoint object. + Expects a tuple of (list[T], PaginationItem) to be returned. + + request_opts: RequestOptions, optional + The request options to pass to the endpoint. If not provided, will use default RequestOptions. + Filters, sorts, page size, starting page number, etc can be set here. + + Yields + ------ + T + The items returned from the endpoint. + + Raises + ------ + ValueError + If the endpoint is not a callable or an Endpoint object. """ def __init__( diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 2a5bb805a..c37c0ce42 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -35,6 +35,28 @@ def apply_query_params(self, url): class RequestOptions(RequestOptionsBase): + """ + This class is used to manage the options that can be used when querying content on the server. + Optionally initialize with a page number and page size to control the number of items returned. + + Additionally, you can add sorting and filtering options to the request. + + The `sort` and `filter` options are set-like objects, so you can only add a field once. If you add the same field + multiple times, only the last one will be used. + + The list of fields that can be sorted on or filtered by can be found in the `Field` + class contained within this class. + + Parameters + ---------- + pagenumber: int, optional + The page number to start the query on. Default is 1. + + pagesize: int, optional + The number of items to return per page. Default is 100. Can also read + from the environment variable `TSC_PAGE_SIZE` + """ + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber self.pagesize = pagesize or config.PAGE_SIZE @@ -199,13 +221,43 @@ def get_query_params(self): def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views + + Parameters + ---------- + name: str + The name of the column to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_filters.append((name, value)) return self def parameter(self, name: str, value: str) -> Self: """Apply a filter based on a parameter within the workbook. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false'""" + Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' + + Parameters + ---------- + name: str + The name of the parameter to filter on + + value: str + The value to filter on + + Returns + ------- + Self + The current object + """ self.view_parameters.append((name, value)) return self @@ -257,14 +309,60 @@ def get_query_params(self) -> dict: class CSVRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to CSV. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "csv" class ExcelRequestOptions(_DataExportOptions): + """ + Options that can be used when exporting a view to Excel. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + extension = "xlsx" class ImageRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to an image. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + imageresolution: str, optional + The resolution of the image to export. Valid values are "high" or None. Default is None. + Image width and actual pixel density are determined by the display context + of the image. Aspect ratio is always preserved. Set the value to "high" to + ensure maximum pixel density. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + + """ + extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution @@ -283,6 +381,29 @@ def get_query_params(self): class PDFRequestOptions(_ImagePDFCommonExportOptions): + """ + Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. + Filters to the underlying data can be applied using the `vf` and `parameter` methods. + + Parameters + ---------- + page_type: str, optional + The page type of the PDF to export. Valid values are accessible via the `PageType` class. + + orientation: str, optional + The orientation of the PDF to export. Valid values are accessible via the `Orientation` class. + + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + + viz_height: int, optional + The height of the viz in pixels. If specified, viz_width must also be specified. + + viz_width: int, optional + The width of the viz in pixels. If specified, viz_height must also be specified. + """ + class PageType: A3 = "a3" A4 = "a4" diff --git a/tableauserverclient/server/sort.py b/tableauserverclient/server/sort.py index 839a8c8db..b78645921 100644 --- a/tableauserverclient/server/sort.py +++ b/tableauserverclient/server/sort.py @@ -1,4 +1,18 @@ class Sort: + """ + Used with request options (RequestOptions) where you can filter and sort on + the results returned from the server. + + Parameters + ---------- + field : str + Sets the field to sort on. The fields are defined in the RequestOption class. + + direction : str + The direction to sort, either ascending (Asc) or descending (Desc). The + options are defined in the RequestOptions.Direction class. + """ + def __init__(self, field, direction): self.field = field self.direction = direction From 55d592abb99d58ef4c331de778d8cc20a0ae6572 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 14:42:45 -0600 Subject: [PATCH 544/567] docs: docstrings for group endpoint and item (#1499) --- tableauserverclient/models/group_item.py | 40 +++ .../server/endpoint/groups_endpoint.py | 336 +++++++++++++++++- 2 files changed, 365 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 6871f8b16..0afd5582c 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -12,6 +12,46 @@ class GroupItem: + """ + The GroupItem class contains the attributes for the group resources on + Tableau Server. The GroupItem class defines the information you can request + or query from Tableau Server. The class members correspond to the attributes + of a server request or response payload. + + Parameters + ---------- + name: str + The name of the group. + + domain_name: str + The name of the Active Directory domain ("local" if local authentication is used). + + Properties + ---------- + users: Pager[UserItem] + The users in the group. Must be populated with a call to `populate_users()`. + + id: str + The unique identifier for the group. + + minimum_site_role: str + The minimum site role for users in the group. Use the `UserItem.Roles` enum. + Users in the group cannot have their site role set lower than this value. + + license_mode: str + The mode defining when to apply licenses for group members. When the + mode is onLogin, a license is granted for each group member when they + login to a site. When the mode is onSync, a license is granted for group + members each time the domain is synced. + + Examples + -------- + >>> # Create a new group item + >>> newgroup = TSC.GroupItem('My Group') + + + """ + tag_name: str = "group" class LicenseMode: diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index c512b011b..4e9af4076 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -18,13 +18,56 @@ class Groups(QuerysetEndpoint[GroupItem]): + """ + Groups endpoint for creating, reading, updating, and deleting groups on + Tableau Server. + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: - """Gets all groups""" + """ + Returns information about the groups on the site. + + To get information about the users in a group, you must first populate + the GroupItem with user information using the groups.populate_users + method. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#query_groups + + Parameters + ---------- + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + additional parameters to filter the request. For example, if you + were searching for a specific group, you could specify the name of + the group or the group id. + + Returns + ------- + tuple[list[GroupItem], PaginationItem] + + Examples + -------- + >>> # import tableauserverclient as TSC + >>> # tableau_auth = TSC.TableauAuth('USERNAME', 'PASSWORD') + >>> # server = TSC.Server('https://SERVERURL') + + >>> with server.auth.sign_in(tableau_auth): + + >>> # get the groups on the server + >>> all_groups, pagination_item = server.groups.get() + + >>> # print the names of the first 100 groups + >>> for group in all_groups : + >>> print(group.name, group.id) + + + + """ logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -34,7 +77,42 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Grou @api(version="2.0") def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: - """Gets all users in a given group""" + """ + Populates the group_item with the list of users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_users_in_group + + Parameters + ---------- + group_item : GroupItem + The group item to populate with user information. + + req_options : Optional[RequestOptions] + (Optional) You can pass the method a request object that contains + page size and page number. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group item does not have an ID, the method raises an error. + + Examples + -------- + >>> # Get the group item from the server + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + + >>> # Populate the group with user information + >>> server.groups.populate_users(group) + >>> for user in group.users: + >>> print(user.name) + + + """ if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -61,7 +139,32 @@ def _get_users_for_group( @api(version="2.0") def delete(self, group_id: str) -> None: - """Deletes 1 group by id""" + """ + Deletes the group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#delete_group + + Parameters + ---------- + group_id: str + The id for the group you want to remove from the server + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_id is not provided, the method raises an error. + + Examples + -------- + >>> groups, pagination_item = server.groups.get() + >>> group = groups[1] + >>> server.groups.delete(group.id) + + """ if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -69,8 +172,42 @@ def delete(self, group_id: str) -> None: self.delete_request(url) logger.info(f"Deleted single group (ID: {group_id})") + @overload + def update(self, group_item: GroupItem, as_job: Literal[False]) -> GroupItem: ... + + @overload + def update(self, group_item: GroupItem, as_job: Literal[True]) -> JobItem: ... + @api(version="2.0") - def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: + def update(self, group_item, as_job=False): + """ + Updates a group on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#update_group + + Parameters + ---------- + group_item : GroupItem + The group item to update. + + as_job : bool + (Optional) If this value is set to True, the update operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the group_item is a local group and as_job is set to True, the + method raises an error. + """ url = f"{self.baseurl}/{group_item.id}" if not group_item.id: @@ -92,15 +229,71 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: - """Create a 'local' Tableau group""" + """ + Create a 'local' Tableau group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + Returns + ------- + GroupItem + + Examples + -------- + >>> new_group = TSC.GroupItem('new_group') + >>> new_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_group = server.groups.create(new_group) + + """ url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[False]) -> GroupItem: ... + + @overload + def create_AD_group(self, group_item: GroupItem, asJob: Literal[True]) -> JobItem: ... + @api(version="2.0") - def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: - """Create a group based on Active Directory""" + def create_AD_group(self, group_item, asJob=False): + """ + Create a group based on Active Directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#create_group + + Parameters + ---------- + group_item : GroupItem + The group item to create. The group_item specifies the group to add. + You first create a new instance of a GroupItem and pass that to this + method. + + asJob : bool + (Optional) If this value is set to True, the create operation will + be asynchronous and return a JobItem. This is only supported for + Active Directory groups. By default, this value is set to False. + + Returns + ------- + Union[GroupItem, JobItem] + + Examples + -------- + >>> new_ad_group = TSC.GroupItem('new_ad_group') + >>> new_ad_group.domain_name = 'example.com' + >>> new_ad_group.minimum_site_role = TSC.UserItem.Role.ExplorerCanPublish + >>> new_ad_group.license_mode = TSC.GroupItem.LicenseMode.onSync + >>> new_ad_group = server.groups.create_AD_group(new_ad_group) + """ asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -112,7 +305,37 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: - """Removes 1 user from 1 group""" + """ + Removes 1 user from 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + user_id : str + The ID of the user to remove from the group. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> server.groups.remove_user(group, group.users[0].id) + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -125,7 +348,37 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: @api(version="3.21") def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: - """Removes multiple users from 1 group""" + """ + Removes multiple users from 1 group. This makes a single API call to + remove the provided users. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#remove_users_to_group + + Parameters + ---------- + group_item : GroupItem + The group item from which to remove the user. + + users : Iterable[Union[str, UserItem]] + The IDs or UserItems with IDs of the users to remove from the group. + + Returns + ------- + None + + Raises + ------ + ValueError + If the group_item is not a GroupItem or str, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.populate_users(group) + >>> users = [u for u in group.users if u.domain_name == 'example.com'] + >>> server.groups.remove_users(group, users) + + """ group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): raise ValueError(f"Invalid group provided: {group_item}") @@ -138,7 +391,37 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: - """Adds 1 user to 1 group""" + """ + Adds 1 user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : str + The ID of the user to add to the group. + + Returns + ------- + UserItem + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> server.groups.add_user(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -154,6 +437,37 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: @api(version="3.21") def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + """ + Adds 1 or more user to 1 group + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_group + + Parameters + ---------- + group_item : GroupItem + The group item to which to add the user. + + user_id : Iterable[Union[str, UserItem]] + User IDs or UserItems with IDs to add to the group. + + Returns + ------- + list[UserItem] + UserItem for the user that was added to the group. + + Raises + ------ + MissingRequiredFieldError + If the group_item does not have an ID, the method raises an error. + + ValueError + If the user_id is not provided, the method raises an error. + + Examples + -------- + >>> group = server.groups.get_by_id('1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + >>> added_users = server.groups.add_users(group, '1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p') + """ """Adds multiple users to 1 group""" group_id = group_item.id if hasattr(group_item, "id") else group_item if not isinstance(group_id, str): From f9341a4be947c96c7b5e0d850aac374bcad5bc6f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 15:08:58 -0600 Subject: [PATCH 545/567] docs: project docstrings (#1505) * docs: project docstrings * docs: add REST API links --- tableauserverclient/models/project_item.py | 38 ++ .../server/endpoint/projects_endpoint.py | 600 ++++++++++++++++++ 2 files changed, 638 insertions(+) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index b20cb5374..9be1196ba 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -9,6 +9,44 @@ class ProjectItem: + """ + The project resources for Tableau are defined in the ProjectItem class. The + class corresponds to the project resources you can access using the Tableau + Server REST API. + + Parameters + ---------- + name : str + Name of the project. + + description : str + Description of the project. + + content_permissions : str + Sets or shows the permissions for the content in the project. The + options are either LockedToProject, ManagedByOwner or + LockedToProjectWithoutNested. + + parent_id : str + The id of the parent project. Use this option to create project + hierarchies. For information about managing projects, project + hierarchies, and permissions, see + https://help.tableau.com/current/server/en-us/projects.htm + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Attributes + ---------- + id : str + The unique identifier for the project. + + owner_id : str + The unique identifier for the UserItem owner of the project. + + """ + ERROR_MSG = "Project item must be populated with permissions first." class ContentPermissions: diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 74bb865c7..68eb573cc 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -20,6 +20,11 @@ class Projects(QuerysetEndpoint[ProjectItem]): + """ + The project methods are based upon the endpoints for projects in the REST + API and operate on the ProjectItem class. + """ + def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -32,6 +37,23 @@ def baseurl(self) -> str: @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + """ + Retrieves all projects on the site. The endpoint is paginated and can + be filtered using the req_options parameter. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#query_projects + + Parameters + ---------- + req_options : RequestOptions | None, default None + The request options to filter the projects. The default is None. + + Returns + ------- + tuple[list[ProjectItem], PaginationItem] + Returns a tuple containing a list of ProjectItem objects and a + PaginationItem object. + """ logger.info("Querying all projects on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -41,6 +63,25 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Proj @api(version="2.0") def delete(self, project_id: str) -> None: + """ + Deletes a single project on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#delete_project + + Parameters + ---------- + project_id : str + The unique identifier for the project. + + Returns + ------- + None + + Raises + ------ + ValueError + If the project ID is not defined, an error is raised. + """ if not project_id: error = "Project ID undefined." raise ValueError(error) @@ -50,6 +91,36 @@ def delete(self, project_id: str) -> None: @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Modify the project settings. + + You can use this method to update the project name, the project + description, or the project permissions. To specify the site, create a + TableauAuth instance using the content URL for the site (site_id), and + sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#update_project + + Parameters + ---------- + project_item : ProjectItem + The project item object must include the project ID. The values in + the project item override the current project settings. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the updated project item. + + Raises + ------ + MissingRequiredFieldError + If the project item is missing the ID, an error is raised. + """ if not project_item.id: error = "Project item missing ID." raise MissingRequiredFieldError(error) @@ -64,6 +135,32 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: + """ + Creates a project on the specified site. + + To create a project, you first create a new instance of a ProjectItem + and pass it to the create method. To specify the site to create the new + project, create a TableauAuth instance using the content URL for the + site (site_id), and sign in to that site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#create_project + + Parameters + ---------- + project_item : ProjectItem + Specifies the properties for the project. The project_item is the + request package. To create the request package, create a new + instance of ProjectItem. + + samples : bool + Set to True to include sample workbooks and data sources in the + project. The default is False. + + Returns + ------- + ProjectItem + Returns the new project item. + """ params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl if project_item._samples: @@ -76,136 +173,639 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte @api(version="2.0") def populate_permissions(self, item: ProjectItem) -> None: + """ + Queries the project permissions, parses and stores the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_project_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Updates the permissions for the specified project item. The rules + provided are expected to be a complete list of the permissions for the + project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ + return self._permissions.update(item, rules) @api(version="2.0") def delete_permission(self, item: ProjectItem, rules: list[PermissionsRule]) -> None: + """ + Deletes the specified permissions from the project item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_project_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete permissions from. + + rules : list[PermissionsRule] + The list of permissions rules to delete from the project. + + Returns + ------- + None + """ self._permissions.delete(item, rules) @api(version="2.1") def populate_workbook_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default workbook permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default workbook permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Workbook) @api(version="2.1") def populate_datasource_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datasource permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datasource permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datasource) @api(version="3.2") def populate_metric_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default metric permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default metric permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Metric) @api(version="3.4") def populate_datarole_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default datarole permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default datarole permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Datarole) @api(version="3.4") def populate_flow_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default flow permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default flow permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Flow) @api(version="3.4") def populate_lens_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default lens permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default lens permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Lens) @api(version="3.23") def populate_virtualconnection_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default virtualconnections permissions, parses and stores + the returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default virtual connection + permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.VirtualConnection) @api(version="3.23") def populate_database_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default database permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default database permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Database) @api(version="3.23") def populate_table_default_permissions(self, item: ProjectItem) -> None: + """ + Queries the default table permissions, parses and stores the + returned the permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions + + Parameters + ---------- + item : ProjectItem + The project item to populate with default table permissions. + + Returns + ------- + None + """ self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="2.1") def update_workbook_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default workbook permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default workbook permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Workbook) @api(version="2.1") def update_datasource_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datasource permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datasource permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datasource) @api(version="3.2") def update_metric_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default metric permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default metric permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Metric) @api(version="3.4") def update_datarole_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default datarole permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default datarole permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Datarole) @api(version="3.4") def update_flow_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default flow permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default flow permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Flow) @api(version="3.4") def update_lens_default_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ + Add or updates the default lens permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default lens permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Lens) @api(version="3.23") def update_virtualconnection_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default virtualconnection permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default virtualconnection permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.VirtualConnection) @api(version="3.23") def update_database_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default database permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default database permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Database) @api(version="3.23") def update_table_default_permissions( self, item: ProjectItem, rules: list[PermissionsRule] ) -> list[PermissionsRule]: + """ + Add or updates the default table permissions for the specified. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_default_permissions_for_content + + Parameters + ---------- + item : ProjectItem + The project item to update default table permissions for. + + rules : list[PermissionsRule] + The list of permissions rules to update the project with. + + Returns + ------- + list[PermissionsRule] + Returns the updated list of permissions rules. + """ return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="2.1") def delete_workbook_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Workbook) @api(version="2.1") def delete_datasource_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datasource permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datasource) @api(version="3.2") def delete_metric_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default workbook permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Metric) @api(version="3.4") def delete_datarole_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default datarole permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Datarole) @api(version="3.4") def delete_flow_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default flow permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Flow) @api(version="3.4") def delete_lens_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default lens permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Lens) @api(version="3.23") def delete_virtualconnection_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default virtualconnection permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.VirtualConnection) @api(version="3.23") def delete_database_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default database permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Database) @api(version="3.23") def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsRule) -> None: + """ + Deletes the specified default permission rule from the project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_default_permission + + Parameters + ---------- + item : ProjectItem + The project item to delete default table permissions from. + + rule : PermissionsRule + The permissions rule to delete from the project. + + Returns + ------- + None + """ self._default_permissions.delete_default_permission(item, rule, Resource.Table) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: From a43355aec83b2517a932d786440221248e6eb95d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 15:11:41 -0600 Subject: [PATCH 546/567] docs: docstrings for JobItem and Jobs endpoint (#1529) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/job_item.py | 65 +++++++++++ .../server/endpoint/jobs_endpoint.py | 109 ++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index cc7cd5811..6286275c5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -8,6 +8,71 @@ class JobItem: + """ + Using the TSC library, you can get information about an asynchronous process + (or job) on the server. These jobs can be created when Tableau runs certain + tasks that could be long running, such as importing or synchronizing users + from Active Directory, or running an extract refresh. For example, the REST + API methods to create or update groups, to run an extract refresh task, or + to publish workbooks can take an asJob parameter (asJob-true) that creates a + background process (the job) to complete the call. Information about the + asynchronous job is returned from the method. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The job properties are defined in the JobItem class. The class corresponds + to the properties for jobs you can access using the Tableau Server REST API. + The job methods are based upon the endpoints for jobs in the REST API and + operate on the JobItem class. + + Parameters + ---------- + id_ : str + The identifier of the job. + + job_type : str + The type of job. + + progress : str + The progress of the job. + + created_at : datetime.datetime + The date and time the job was created. + + started_at : Optional[datetime.datetime] + The date and time the job was started. + + completed_at : Optional[datetime.datetime] + The date and time the job was completed. + + finish_code : int + The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. + + notes : Optional[list[str]] + Contains detailed notes about the job. + + mode : Optional[str] + + workbook_id : Optional[str] + The identifier of the workbook associated with the job. + + datasource_id : Optional[str] + The identifier of the datasource associated with the job. + + flow_run : Optional[FlowRunItem] + The flow run associated with the job. + + updated_at : Optional[datetime.datetime] + The date and time the job was last updated. + + workbook_name : Optional[str] + The name of the workbook associated with the job. + + datasource_name : Optional[str] + The name of the datasource associated with the job. + """ + class FinishCode: """ Status codes as documented on diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 723d3dd38..027a7ca12 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -33,6 +33,32 @@ def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> @api(version="2.6") def get(self, job_id=None, req_options=None): + """ + Retrieve jobs for the site. Endpoint is paginated and will return a + list of jobs and pagination information. If a job_id is provided, the + method will return information about that specific job. Specifying a + job_id is deprecated and will be removed in a future version. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_jobs + + Parameters + ---------- + job_id : str or RequestOptionsBase + The ID of the job to retrieve. If None, the method will return all + jobs for the site. If a RequestOptions object is provided, the + method will use the options to filter the jobs. + + req_options : RequestOptionsBase + The request options to filter the jobs. If None, the method will + return all jobs for the site. + + Returns + ------- + tuple[list[BackgroundJobItem], PaginationItem] or JobItem + If a job_id is provided, the method will return a JobItem. If no + job_id is provided, the method will return a tuple containing a + list of BackgroundJobItems and a PaginationItem. + """ # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -50,6 +76,33 @@ def get(self, job_id=None, req_options=None): @api(version="3.1") def cancel(self, job_id: Union[str, JobItem]): + """ + Cancels a job specified by job ID. To get a list of job IDs for jobs that are currently queued or in-progress, use the Query Jobs method. + + The following jobs can be canceled using the Cancel Job method: + + Full extract refresh + Incremental extract refresh + Subscription + Flow Run + Data Acceleration (Data acceleration is not available in Tableau Server 2022.1 (API 3.16) and later. See View Acceleration(Link opens in a new window).) + Bridge full extract refresh + Bridge incremental extract refresh + Queue upgrade Thumbnail (Job that puts the upgrade thumbnail job on the queue) + Upgrade Thumbnail + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#cancel_job + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to cancel. If a JobItem is provided, the method + will use the ID from the JobItem. + + Returns + ------- + None + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) @@ -58,6 +111,32 @@ def cancel(self, job_id: Union[str, JobItem]): @api(version="2.6") def get_by_id(self, job_id: str) -> JobItem: + """ + Returns status information about an asynchronous process that is tracked + using a job. This method can be used to query jobs that are used to do + the following: + + Import users from Active Directory (the result of a call to Create Group). + Synchronize an existing Tableau Server group with Active Directory (the result of a call to Update Group). + Run extract refresh tasks (the result of a call to Run Extract Refresh Task). + Publish a workbook asynchronously (the result of a call to Publish Workbook). + Run workbook or view subscriptions (the result of a call to Create Subscription or Update Subscription) + Run a flow task (the result of a call to Run Flow Task) + Status of Tableau Server site deletion (the result of a call to asynchronous Delete Site(Link opens in a new window) beginning API 3.18) + Note: To query a site deletion job, the server administrator must be first signed into the default site (contentUrl=" "). + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job + + Parameters + ---------- + job_id : str + The ID of the job to retrieve. + + Returns + ------- + JobItem + The JobItem object that contains information about the requested job. + """ logger.info("Query for information about job " + job_id) url = f"{self.baseurl}/{job_id}" server_response = self.get_request(url) @@ -65,6 +144,36 @@ def get_by_id(self, job_id: str) -> JobItem: return new_job def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: + """ + Waits for a job to complete. The method will poll the server for the job + status until the job is completed. If the job is successful, the method + will return the JobItem. If the job fails, the method will raise a + JobFailedException. If the job is cancelled, the method will raise a + JobCancelledException. + + Parameters + ---------- + job_id : str or JobItem + The ID of the job to wait for. If a JobItem is provided, the method + will use the ID from the JobItem. + + timeout : float | None + The maximum amount of time to wait for the job to complete. If None, + the method will wait indefinitely. + + Returns + ------- + JobItem + The JobItem object that contains information about the completed job. + + Raises + ------ + JobFailedException + If the job failed to complete. + + JobCancelledException + If the job was cancelled. + """ if isinstance(job_id, JobItem): job_id = job_id.id assert isinstance(job_id, str) From 1020485db3dd37b475562e75a1f58a59ad8d9d98 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 16:27:12 -0600 Subject: [PATCH 547/567] docs: docstrings on ConnectionItem and ConnectionCredentials (#1526) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/connection_credentials.py | 18 ++++++++- tableauserverclient/models/connection_item.py | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/connection_credentials.py b/tableauserverclient/models/connection_credentials.py index bb2cbbba9..aaa2f1bed 100644 --- a/tableauserverclient/models/connection_credentials.py +++ b/tableauserverclient/models/connection_credentials.py @@ -2,11 +2,27 @@ class ConnectionCredentials: - """Connection Credentials for Workbooks and Datasources publish request. + """ + Connection Credentials for Workbooks and Datasources publish request. Consider removing this object and other variables holding secrets as soon as possible after use to avoid them hanging around in memory. + Parameters + ---------- + name: str + The username for the connection. + + password: str + The password used for the connection. + + embed: bool, default True + Determines whether to embed the password (True) for the workbook or data source connection or not (False). + + oauth: bool, default False + Determines whether to use OAuth for the connection (True) or not (False). + For more information see: https://help.tableau.com/current/server/en-us/protected_auth.htm + """ def __init__(self, name, password, embed=True, oauth=False): diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 937e43481..e68958c3b 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -9,6 +9,44 @@ class ConnectionItem: + """ + Corresponds to workbook and data source connections. + + Attributes + ---------- + datasource_id: str + The identifier of the data source. + + datasource_name: str + The name of the data source. + + id: str + The identifier of the connection. + + connection_type: str + The type of connection. + + username: str + The username for the connection. (see ConnectionCredentials) + + password: str + The password used for the connection. (see ConnectionCredentials) + + embed_password: bool + Determines whether to embed the password (True) for the workbook or data source connection or not (False). (see ConnectionCredentials) + + server_address: str + The server address for the connection. + + server_port: str + The port used for the connection. + + connection_credentials: ConnectionCredentials + The Connection Credentials object containing authentication details for + the connection. Replaces username/password/embed_password when + publishing a flow, document or workbook file in the request body. + """ + def __init__(self): self._datasource_id: Optional[str] = None self._datasource_name: Optional[str] = None From 20354813bea41244d0e13001bad010c1f107e2e3 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 16:34:35 -0600 Subject: [PATCH 548/567] docs: flow docstrings (#1532) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/flow_item.py | 53 ++- .../server/endpoint/flows_endpoint.py | 314 ++++++++++++++++++ 2 files changed, 363 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 9bcad5e89..0083776bb 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional +from typing import Iterable, Optional from defusedxml.ElementTree import fromstring @@ -15,6 +15,51 @@ class FlowItem: + """ + Represents a Tableau Flow item. + + Parameters + ---------- + project_id: str + The ID of the project that the flow belongs to. + + name: Optional[str] + The name of the flow. + + Attributes + ---------- + connections: Iterable[ConnectionItem] + The connections associated with the flow. This property is not populated + by default and must be populated by calling the `populate_connections` + method. + + created_at: Optional[datetime.datetime] + The date and time when the flow was created. + + description: Optional[str] + The description of the flow. + + dqws: Iterable[DQWItem] + The data quality warnings associated with the flow. This property is not + populated by default and must be populated by calling the `populate_dqws` + method. + + id: Optional[str] + The ID of the flow. + + name: Optional[str] + The name of the flow. + + owner_id: Optional[str] + The ID of the user who owns the flow. + + project_name: Optional[str] + The name of the project that the flow belongs to. + + tags: set[str] + The tags associated with the flow. + """ + def __repr__(self): return " None: self.tags: set[str] = set() self.description: Optional[str] = None - self._connections: Optional[ConnectionItem] = None - self._permissions: Optional[Permission] = None - self._data_quality_warnings: Optional[DQWItem] = None + self._connections: Optional[Iterable[ConnectionItem]] = None + self._permissions: Optional[Iterable[Permission]] = None + self._data_quality_warnings: Optional[Iterable[DQWItem]] = None @property def connections(self): diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 7eb5dc3ba..42c9d4c1e 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -66,6 +66,25 @@ def baseurl(self) -> str: # Get all flows @api(version="3.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + """ + Get all flows on site. Returns a tuple of all flow items and pagination item. + This method is paginated, and returns one page of items per call. The + request options can be used to specify the page number, page size, as + well as sorting and filtering options. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flows_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions] + An optional Request Options object that can be used to specify + sorting, filtering, and pagination options. + + Returns + ------- + tuple[list[FlowItem], PaginationItem] + A tuple of a list of flow items and a pagination item. + """ logger.info("Querying all flows on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -76,6 +95,21 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Flow # Get 1 flow by id @api(version="3.3") def get_by_id(self, flow_id: str) -> FlowItem: + """ + Get a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow + + Parameters + ---------- + flow_id: str + The id of the flow to retrieve. + + Returns + ------- + FlowItem + The flow item that was retrieved. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -87,6 +121,27 @@ def get_by_id(self, flow_id: str) -> FlowItem: # Populate flow item's connections @api(version="3.3") def populate_connections(self, flow_item: FlowItem) -> None: + """ + Populate the connections for a flow item. This method will make a + request to the Tableau Server to get the connections associated with + the flow item and populate the connections property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_connections + + Parameters + ---------- + flow_item: FlowItem + The flow item to populate connections for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the flow item does not have an ID. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -106,6 +161,25 @@ def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions # Delete 1 flow by id @api(version="3.3") def delete(self, flow_id: str) -> None: + """ + Delete a single flow by id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow + + Parameters + ---------- + flow_id: str + The id of the flow to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -116,6 +190,35 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: + """ + Download a single flow by id. The flow will be downloaded to the + specified file path. If no file path is specified, the flow will be + downloaded to the current working directory. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#download_flow + + Parameters + ---------- + flow_id: str + The id of the flow to download. + + filepath: Optional[PathOrFileW] + The file path to download the flow to. This can be a file path or + a file object. If a file object is passed, the flow will be written + to the file object. If a file path is passed, the flow will be + written to the file path. If no file path is specified, the flow + will be downloaded to the current working directory. + + Returns + ------- + PathOrFileW + The file path or file object that the flow was downloaded to. + + Raises + ------ + ValueError + If the flow_id is not defined. + """ if not flow_id: error = "Flow ID undefined." raise ValueError(error) @@ -144,6 +247,21 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path # Update flow @api(version="3.3") def update(self, flow_item: FlowItem) -> FlowItem: + """ + Updates the flow owner, project, description, and/or tags. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to update. + + Returns + ------- + FlowItem + The updated flow item. + """ if not flow_item.id: error = "Flow item missing ID. Flow must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -161,6 +279,25 @@ def update(self, flow_item: FlowItem) -> FlowItem: # Update flow connections @api(version="3.3") def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem) -> ConnectionItem: + """ + Update a connection item for a flow item. This method will update the + connection details for the connection item associated with the flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#update_flow_connection + + Parameters + ---------- + flow_item: FlowItem + The flow item that the connection is associated with. + + connection_item: ConnectionItem + The connection item to update. + + Returns + ------- + ConnectionItem + The updated connection item. + """ url = f"{self.baseurl}/{flow_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -172,6 +309,21 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem @api(version="3.3") def refresh(self, flow_item: FlowItem) -> JobItem: + """ + Runs the flow to refresh the data. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#run_flow_now + + Parameters + ---------- + flow_item: FlowItem + The flow item to refresh. + + Returns + ------- + JobItem + The job item that was created to refresh the flow. + """ url = f"{self.baseurl}/{flow_item.id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) @@ -183,6 +335,35 @@ def refresh(self, flow_item: FlowItem) -> JobItem: def publish( self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None ) -> FlowItem: + """ + Publishes a flow to the Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#publish_flow + + Parameters + ---------- + flow_item: FlowItem + The flow item to publish. This item must have a project_id and name + defined. + + file: PathOrFileR + The file path or file object to publish. This can be a .tfl or .tflx + + mode: str + The publish mode. This can be "Overwrite" or "CreatNew". If the + mode is "Overwrite", the flow will be overwritten if it already + exists. If the mode is "CreateNew", a new flow will be created with + the same name as the flow item. + + connections: Optional[list[ConnectionItem]] + A list of connection items to publish with the flow. If the flow + contains connections, they must be included in this list. + + Returns + ------- + FlowItem + The flow item that was published. + """ if not mode or not hasattr(self.parent_srv.PublishMode, mode): error = "Invalid mode defined." raise ValueError(error) @@ -265,30 +446,145 @@ def publish( @api(version="3.3") def populate_permissions(self, item: FlowItem) -> None: + """ + Populate the permissions for a flow item. This method will make a + request to the Tableau Server to get the permissions associated with + the flow item and populate the permissions property of the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow_permissions + + Parameters + ---------- + item: FlowItem + The flow item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: + """ + Update the permissions for a flow item. This method will update the + permissions for the flow item. The permissions must be a list of + permissions rules. Will overwrite all existing permissions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item: FlowItem + The flow item to update permissions for. + + permission_item: Iterable[PermissionsRule] + The permissions rules to update. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="3.3") def delete_permission(self, item: FlowItem, capability_item: "PermissionsRule") -> None: + """ + Delete a permission for a flow item. This method will delete only the + specified permission for the flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#delete_flow_permission + + Parameters + ---------- + item: FlowItem + The flow item to delete the permission from. + + capability_item: PermissionsRule + The permission to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") def populate_dqw(self, item: FlowItem) -> None: + """ + Get information about Data Quality Warnings for a flow item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item: FlowItem + The flow item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") def update_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Update the warning type, status, and message of a data quality warning + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item: FlowItem + The flow item to update data quality warnings for. + + warning: DQWItem + The data quality warning to update. + + Returns + ------- + None + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") def add_dqw(self, item: FlowItem, warning: "DQWItem") -> None: + """ + Add a data quality warning to a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: FlowItem + The flow item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + None + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") def delete_dqw(self, item: FlowItem) -> None: + """ + Delete all data quality warnings for a flow. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: FlowItem + The flow item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # a convenience method @@ -296,6 +592,24 @@ def delete_dqw(self, item: FlowItem) -> None: def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> list["AddResponse"]: # actually should return a task + """ + Schedule a flow to run on an existing schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id: str + The id of the schedule to add the flow to. + + item: FlowItem + The flow item to add to the schedule. + + Returns + ------- + list[AddResponse] + The response from the server. + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: From ec10c60af783410c6afec6e95d745af43bece11f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 3 Jan 2025 17:55:46 -0600 Subject: [PATCH 549/567] docs: docstrings for views (#1523) * docs: docstrings for views --- tableauserverclient/models/view_item.py | 58 ++++- .../server/endpoint/views_endpoint.py | 245 +++++++++++++++++- 2 files changed, 298 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc5f37a48..88cec7328 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -7,12 +7,64 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .tag_item import TagItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.tag_item import TagItem class ViewItem: + """ + Contains the members or attributes for the view resources on Tableau Server. + The ViewItem class defines the information you can request or query from + Tableau Server. The class members correspond to the attributes of a server + request or response payload. + + Attributes + ---------- + content_url: Optional[str], default None + The name of the view as it would appear in a URL. + + created_at: Optional[datetime], default None + The date and time when the view was created. + + id: Optional[str], default None + The unique identifier for the view. + + image: Optional[Callable[[], bytes]], default None + The image of the view. You must first call the `views.populate_image` + method to access the image. + + name: Optional[str], default None + The name of the view. + + owner_id: Optional[str], default None + The ID for the owner of the view. + + pdf: Optional[Callable[[], bytes]], default None + The PDF of the view. You must first call the `views.populate_pdf` + method to access the PDF. + + preview_image: Optional[Callable[[], bytes]], default None + The preview image of the view. You must first call the + `views.populate_preview_image` method to access the preview image. + + project_id: Optional[str], default None + The ID for the project that contains the view. + + tags: set[str], default set() + The tags associated with the view. + + total_views: Optional[int], default None + The total number of views for the view. + + updated_at: Optional[datetime], default None + The date and time when the view was last updated. + + workbook_id: Optional[str], default None + The ID for the workbook that contains the view. + + """ + def __init__(self) -> None: self._content_url: Optional[str] = None self._created_at: Optional[datetime] = None diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 3709fc41d..12b386876 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,6 +1,7 @@ import logging from contextlib import closing +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint @@ -25,6 +26,12 @@ class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): + """ + The Tableau Server Client provides methods for interacting with view + resources, or endpoints. These methods correspond to the endpoints for views + in the Tableau Server REST API. + """ + def __init__(self, parent_srv): super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -42,6 +49,24 @@ def baseurl(self) -> str: def get( self, req_options: Optional["RequestOptions"] = None, usage: bool = False ) -> tuple[list[ViewItem], PaginationItem]: + """ + Returns the list of views on the site. Paginated endpoint. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_views_for_site + + Parameters + ---------- + req_options: Optional[RequestOptions], default None + The request options for the request. These options can include + parameters such as page size and sorting. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + views: tuple[list[ViewItem], PaginationItem] + """ logger.info("Querying all views on site") url = self.baseurl if usage: @@ -53,6 +78,23 @@ def get( @api(version="3.1") def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: + """ + Returns the details of a specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_view + + Parameters + ---------- + view_id: str + The view ID. + + usage: bool, default False + If True, includes usage statistics in the response. + + Returns + ------- + view_item: ViewItem + """ if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -65,6 +107,24 @@ def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: @api(version="2.0") def populate_preview_image(self, view_item: ViewItem) -> None: + """ + Populates a preview image for the specified view. + + This method gets the preview image (thumbnail) for the specified view + item. The method uses the id and workbook_id fields to query the preview + image. The method populates the preview_image for the view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_with_preview + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the preview image. + + Returns + ------- + None + """ if not view_item.id or not view_item.workbook_id: error = "View item missing ID or workbook ID." raise MissingRequiredFieldError(error) @@ -83,6 +143,27 @@ def _get_preview_for_view(self, view_item: ViewItem) -> bytes: @api(version="2.5") def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + """ + Populates the image of the specified view. + + This method uses the id field to query the image, and populates the + image content as the image field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_image + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the image. + + req_options: Optional[ImageRequestOptions], default None + Optional request options for the request. These options can include + parameters such as image resolution and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -101,6 +182,26 @@ def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageReque @api(version="2.7") def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + """ + Populates the PDF content of the specified view. + + This method populates a PDF with image(s) of the view you specify. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_pdf + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the PDF. + + req_options: Optional[PDFRequestOptions], default None + Optional request options for the request. These options can include + parameters such as orientation and paper size. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -119,6 +220,27 @@ def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOp @api(version="2.7") def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + """ + Populates the CSV data of the specified view. + + This method uses the id field to query the CSV data, and populates the + data as the csv field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_view_data + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the CSV data. + + req_options: Optional[CSVRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -137,6 +259,27 @@ def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOp @api(version="3.8") def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: + """ + Populates the Excel data of the specified view. + + This method uses the id field to query the Excel data, and populates the + data as the Excel field. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#download_view_excel + + Parameters + ---------- + view_item: ViewItem + The view item for which to populate the Excel data. + + req_options: Optional[ExcelRequestOptions], default None + Optional request options for the request. These options can include + parameters such as view filters and max age. + + Returns + ------- + None + """ if not view_item.id: error = "View item missing ID." raise MissingRequiredFieldError(error) @@ -155,18 +298,66 @@ def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelReque @api(version="3.2") def populate_permissions(self, item: ViewItem) -> None: + """ + Returns a list of permissions for the specific view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_view_permissions + + Parameters + ---------- + item: ViewItem + The view item for which to populate the permissions. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="3.2") - def update_permissions(self, resource, rules): + def update_permissions(self, resource: ViewItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: + """ """ return self._permissions.update(resource, rules) @api(version="3.2") - def delete_permission(self, item, capability_item): + def delete_permission(self, item: ViewItem, capability_item: PermissionsRule) -> None: + """ + Deletes permission to the specified view (also known as a sheet) for a + Tableau Server user or group. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_view_permission + + Parameters + ---------- + item: ViewItem + The view item for which to delete the permission. + + capability_item: PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ return self._permissions.delete(item, capability_item) # Update view. Currently only tags can be updated def update(self, view_item: ViewItem) -> ViewItem: + """ + Updates the tags for the specified view. All other fields are managed + through the WorkbookItem object. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + view_item: ViewItem + The view item for which to update tags. + + Returns + ------- + ViewItem + """ if not view_item.id: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -178,14 +369,64 @@ def update(self, view_item: ViewItem) -> ViewItem: @api(version="1.0") def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds tags to the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID to which to add tags. + + tags: Union[Iterable[str], str] + The tags to add to the view. + + Returns + ------- + set[str] + + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes tags from the specified view. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_tags_from_view + + Parameters + ---------- + item: Union[ViewItem, str] + The view item or view ID from which to delete tags. + + tags: Union[Iterable[str], str] + The tags to delete from the view. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: ViewItem) -> None: + """ + Updates the tags for the specified view. Any changes to the tags must + be made by editing the tags attribute of the view item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_view + + Parameters + ---------- + item: ViewItem + The view item for which to update tags. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: From 3275925df6f1284d21b3a9a3137a2a583b32708b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 10 Jan 2025 03:33:25 -0600 Subject: [PATCH 550/567] Jorwoods/datasource refresh hotfix (#1554) * hotfix: Datasource refresh expects empty requests. Closes #1553 --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/datasources_endpoint.py | 3 ++- test/test_datasource.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 1f00af570..5a48f3c93 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -190,7 +190,8 @@ def update_connection( def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + # refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/test/test_datasource.py b/test/test_datasource.py index e8a95722b..b7e7e2721 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -366,6 +366,25 @@ def test_refresh_object(self) -> None: # We only check the `id`; remaining fields are already tested in `test_refresh_id` self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) + def test_datasource_refresh_request_empty(self) -> None: + self.server.version = "2.8" + self.baseurl = self.server.datasources.baseurl + item = TSC.DatasourceItem("") + item._id = "1234" + text = read_xml_asset(REFRESH_XML) + + def match_request_body(request): + try: + root = fromstring(request.body) + assert root.tag == "tsRequest" + assert len(root) == 0 + return True + except Exception: + return False + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body) + def test_update_hyper_data_datasource_object(self) -> None: """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" self.server.version = "3.13" From 19128068e29f02009fb0b7a978710dd35af7a548 Mon Sep 17 00:00:00 2001 From: Dmytro Kulyk <34435869+KulykDmytro@users.noreply.github.com> Date: Thu, 23 Jan 2025 01:45:12 +0200 Subject: [PATCH 551/567] Fixed incorrect size unit when logging fileUpload (#1560) Update fileuploads_endpoint.py fix size unit when logging fileUpload Co-authored-by: Jac --- tableauserverclient/server/endpoint/fileuploads_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index 1ae10e72d..c1749af40 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -56,6 +56,6 @@ def upload(self, file): request, content_type = RequestFactory.Fileupload.chunk_req(chunk) logger.debug(f"{datetime.timestamp()} created chunk request") fileupload_item = self.append(upload_id, request, content_type) - logger.info(f"\t{datetime.timestamp()} Published {(fileupload_item.file_size / BYTES_PER_MB)}MB") + logger.info(f"\t{datetime.timestamp()} Published {fileupload_item.file_size}MB") logger.info(f"File upload finished (ID: {upload_id})") return upload_id From 364f4313b3973355491a499bad892f8a579709b2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 23 Jan 2025 03:49:11 -0600 Subject: [PATCH 552/567] docs: DatasourceItem and Endpoint docstrings (#1556) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 129 +++- .../server/endpoint/datasources_endpoint.py | 560 +++++++++++++++++- 2 files changed, 666 insertions(+), 23 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 1b082c157..2005edf7e 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -19,6 +19,93 @@ class DatasourceItem: + """ + Represents a Tableau datasource item. + + Parameters + ---------- + project_id : Optional[str] + The project ID that the datasource belongs to. + + name : Optional[str] + The name of the datasource. + + Attributes + ---------- + ask_data_enablement : Optional[str] + Determines if a data source allows use of Ask Data. The value can be + TSC.DatasourceItem.AskDataEnablement.Enabled, + TSC.DatasourceItem.AskDataEnablement.Disabled, or + TSC.DatasourceItem.AskDataEnablement.SiteDefault. If no setting is + specified, it will default to SiteDefault. See REST API Publish + Datasource for more information about ask_data_enablement. + + connections : list[ConnectionItem] + The list of data connections (ConnectionItem) for the specified data + source. You must first call the populate_connections method to access + this data. See the ConnectionItem class. + + content_url : Optional[str] + The name of the data source as it would appear in a URL. + + created_at : Optional[datetime.datetime] + The time the data source was created. + + certified : Optional[bool] + A Boolean value that indicates whether the data source is certified. + + certification_note : Optional[str] + The optional note that describes the certified data source. + + datasource_type : Optional[str] + The type of data source, for example, sqlserver or excel-direct. + + description : Optional[str] + The description for the data source. + + encrypt_extracts : Optional[bool] + A Boolean value to determine if a datasource should be encrypted or not. + See Extract and Encryption Methods for more information. + + has_extracts : Optional[bool] + A Boolean value that indicates whether the datasource has extracts. + + id : Optional[str] + The identifier for the data source. You need this value to query a + specific data source or to delete a data source with the get_by_id and + delete methods. + + name : Optional[str] + The name of the data source. If not specified, the name of the published + data source file is used. + + owner_id : Optional[str] + The identifier of the owner of the data source. + + project_id : Optional[str] + The identifier of the project associated with the data source. You must + provide this identifier when you create an instance of a DatasourceItem. + + project_name : Optional[str] + The name of the project associated with the data source. + + tags : Optional[set[str]] + The tags (list of strings) that have been added to the data source. + + updated_at : Optional[datetime.datetime] + The date and time when the data source was last updated. + + use_remote_query_agent : Optional[bool] + A Boolean value that indicates whether to allow or disallow your Tableau + Cloud site to use Tableau Bridge clients. Bridge allows you to maintain + data sources with live connections to supported on-premises data + sources. See Configure and Manage the Bridge Client Pool for more + information. + + webpage_url : Optional[str] + The url of the datasource as displayed in browsers. + """ + class AskDataEnablement: Enabled = "Enabled" Disabled = "Disabled" @@ -33,28 +120,28 @@ def __repr__(self): ) def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: - self._ask_data_enablement = None - self._certified = None - self._certification_note = None - self._connections = None + self._ask_data_enablement: Optional[str] = None + self._certified: Optional[bool] = None + self._certification_note: Optional[str] = None + self._connections: Optional[list[ConnectionItem]] = None self._content_url: Optional[str] = None - self._created_at = None - self._datasource_type = None - self._description = None - self._encrypt_extracts = None - self._has_extracts = None + self._created_at: Optional[datetime.datetime] = None + self._datasource_type: Optional[str] = None + self._description: Optional[str] = None + self._encrypt_extracts: Optional[bool] = None + self._has_extracts: Optional[bool] = None self._id: Optional[str] = None self._initial_tags: set = set() self._project_name: Optional[str] = None self._revisions = None self._size: Optional[int] = None - self._updated_at = None - self._use_remote_query_agent = None - self._webpage_url = None - self.description = None - self.name = name + self._updated_at: Optional[datetime.datetime] = None + self._use_remote_query_agent: Optional[bool] = None + self._webpage_url: Optional[str] = None + self.description: Optional[str] = None + self.name: Optional[str] = name self.owner_id: Optional[str] = None - self.project_id = project_id + self.project_id: Optional[str] = project_id self.tags: set[str] = set() self._permissions = None @@ -63,16 +150,16 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) return None @property - def ask_data_enablement(self) -> Optional[AskDataEnablement]: + def ask_data_enablement(self) -> Optional[str]: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional[AskDataEnablement]): + def ask_data_enablement(self, value: Optional[str]): self._ask_data_enablement = value @property - def connections(self) -> Optional[list[ConnectionItem]]: + def connections(self): if self._connections is None: error = "Datasource item must be populated with connections first." raise UnpopulatedPropertyError(error) @@ -112,7 +199,7 @@ def certification_note(self, value: Optional[str]): self._certification_note = value @property - def encrypt_extracts(self): + def encrypt_extracts(self) -> Optional[bool]: return self._encrypt_extracts @encrypt_extracts.setter @@ -156,7 +243,7 @@ def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value: str): + def description(self, value: Optional[str]): self._description = value @property @@ -187,7 +274,7 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size - def _set_connections(self, connections): + def _set_connections(self, connections) -> None: self._connections = connections def _set_permissions(self, permissions): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 5a48f3c93..e50a74ecb 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,10 +6,11 @@ from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union +from typing import Literal, Optional, TYPE_CHECKING, Union, overload from collections.abc import Iterable, Mapping, Sequence from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: @@ -71,6 +72,28 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + """ + Returns a list of published data sources on the specified site, with + optional parameters for specifying the paging of large results. To get + a list of data sources embedded in a workbook, use the Query Workbook + Connections method. + + Endpoint is paginated, and will return one page per call. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_sources + + Parameters + ---------- + req_options : Optional[RequestOptions] + Optional parameters for the request, such as filters, sorting, page + size, and page number. + + Returns + ------- + tuple[list[DatasourceItem], PaginationItem] + A tuple containing the list of datasource items and pagination + information. + """ logger.info("Querying all datasources on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -81,6 +104,21 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[Dataso # Get 1 datasource by id @api(version="2.0") def get_by_id(self, datasource_id: str) -> DatasourceItem: + """ + Returns information about a specific data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to retrieve. + + Returns + ------- + DatasourceItem + An object containing information about the datasource. + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -92,6 +130,20 @@ def get_by_id(self, datasource_id: str) -> DatasourceItem: # Populate datasource item's connections @api(version="2.0") def populate_connections(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve connection information for the specificed datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source_connections + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve connections for. + + Returns + ------- + None + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -116,6 +168,22 @@ def _get_datasource_connections( # Delete 1 datasource by id @api(version="2.0") def delete(self, datasource_id: str) -> None: + """ + Deletes the specified data source from a site. When a data source is + deleted, its associated data connection is also deleted. Workbooks that + use the data source are not deleted, but they will no longer work + properly. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_data_source + + Parameters + ---------- + datasource_id : str + + Returns + ------- + None + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -133,6 +201,29 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads the specified data source from a site. The data source is + downloaded as a .tdsx file. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ return self.download_revision( datasource_id, None, @@ -143,6 +234,28 @@ def download( # Update datasource @api(version="2.0") def update(self, datasource_item: DatasourceItem) -> DatasourceItem: + """ + Updates the owner, project or certification status of the specified + data source. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + Returns + ------- + DatasourceItem + An object containing information about the updated datasource. + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ + if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -171,6 +284,26 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem ) -> Optional[ConnectionItem]: + """ + Updates the server address, port, username, or password for the + specified data source connection. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to update. + + connection_item : ConnectionItem + The connection item to update. + + Returns + ------- + Optional[ConnectionItem] + An object containing information about the updated connection. + """ + url = f"{self.baseurl}/{datasource_item.id}/connections/{connection_item.id}" update_req = RequestFactory.Connection.update_req(connection_item) @@ -188,6 +321,23 @@ def update_connection( @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: + """ + Refreshes the extract of an existing workbook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#run_extract_refresh_task + + Parameters + ---------- + workbook_item : WorkbookItem | str + The workbook item or workbook ID. + incremental: bool + Whether to do a full refresh or incremental refresh of the extract data + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" # refresh_req = RequestFactory.Task.refresh_req(incremental) @@ -198,6 +348,25 @@ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> @api(version="3.5") def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) -> JobItem: + """ + Create an extract for a data source in a site. Optionally, encrypt the + extract if the site and workbooks using it are configured to allow it. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#create_extract_for_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + encrypt : bool, default False + Whether to encrypt the extract. + + Returns + ------- + JobItem + The job item. + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}" empty_req = RequestFactory.Empty.empty_req() @@ -207,11 +376,49 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False) @api(version="3.5") def delete_extract(self, datasource_item: DatasourceItem) -> None: + """ + Delete the extract of a data source in a site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#delete_extract_from_datasource + + Parameters + ---------- + datasource_item : DatasourceItem | str + The datasource item or datasource ID. + + Returns + ------- + None + """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/deleteExtract" empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[False] = False, + ) -> DatasourceItem: + pass + + @overload + def publish( + self, + datasource_item: DatasourceItem, + file: PathOrFileR, + mode: str, + connection_credentials: Optional[ConnectionCredentials] = None, + connections: Optional[Sequence[ConnectionItem]] = None, + as_job: Literal[True] = True, + ) -> JobItem: + pass + # Publish datasource @api(version="2.0") @parameter_added_in(connections="2.8") @@ -225,6 +432,50 @@ def publish( connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, ) -> Union[DatasourceItem, JobItem]: + """ + Publishes a data source to a server, or appends data to an existing + data source. + + This method checks the size of the data source and automatically + determines whether the publish the data source in multiple parts or in + one operation. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to publish. The fields for name and project_id + are required. + + file : PathOrFileR + The file path or file object to publish. + + mode : str + Specifies whether you are publishing a new datasource (CreateNew), + overwriting an existing datasource (Overwrite), or add to an + existing datasource (Append). You can also use the publish mode + attributes, for example: TSC.Server.PublishMode.Overwrite. + + connection_credentials : Optional[ConnectionCredentials] + The connection credentials to use when publishing the datasource. + Mutually exclusive with the connections parameter. + + connections : Optional[Sequence[ConnectionItem]] + The connections to use when publishing the datasource. Mutually + exclusive with the connection_credentials parameter. + + as_job : bool, default False + If True, the publish operation is asynchronous and returns a job + item. If False, the publish operation is synchronous and returns a + datasource item. + + Returns + ------- + Union[DatasourceItem, JobItem] + The datasource item or job item. + + """ if isinstance(file, (os.PathLike, str)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -329,6 +580,51 @@ def update_hyper_data( actions: Sequence[Mapping], payload: Optional[FilePath] = None, ) -> JobItem: + """ + Incrementally updates data (insert, update, upsert, replace and delete) + in a published data source from a live-to-Hyper connection, where the + data source has multiple connections. + + A live-to-Hyper connection has a Hyper or Parquet formatted + file/database as the origin of its data. + + For all connections to Parquet files, and for any data sources with a + single connection generally, you can use the Update Data in Hyper Data + Source method without specifying the connection-id. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_in_hyper_connection + + Parameters + ---------- + datasource_or_connection_item : Union[DatasourceItem, ConnectionItem, str] + The datasource item, connection item, or datasource ID. Either a + DataSourceItem or a ConnectionItem. If the datasource only contains + a single connection, the DataSourceItem is sufficient to identify + which data should be updated. Otherwise, for datasources with + multiple connections, a ConnectionItem must be provided. + + request_id : str + User supplied arbitrary string to identify the request. A request + identified with the same key will only be executed once, even if + additional requests using the key are made, for instance, due to + retries when facing network issues. + + actions : Sequence[Mapping] + A list of actions (insert, update, delete, ...) specifying how to + modify the data within the published datasource. For more + information on the actions, see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm#action-batch-descriptions + + payload : Optional[FilePath] + A Hyper file containing tuples to be inserted/deleted/updated or + other payload data used by the actions. Hyper files can be created + using the Tableau Hyper API or pantab. + + Returns + ------- + JobItem + The job running on the server. + + """ if isinstance(datasource_or_connection_item, DatasourceItem): datasource_id = datasource_or_connection_item.id url = f"{self.baseurl}/{datasource_id}/data" @@ -357,35 +653,179 @@ def update_hyper_data( @api(version="2.0") def populate_permissions(self, item: DatasourceItem) -> None: + """ + Populates the permissions on the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_data_source_permissions + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate permissions for. + + Returns + ------- + None + """ self._permissions.populate(item) @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: list["PermissionsRule"]) -> None: + """ + Updates the permissions on the specified datasource item. This method + overwrites all existing permissions. Any permissions not included in + the list will be removed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#replace_permissions_for_content + + Parameters + ---------- + item : DatasourceItem + The datasource item to update permissions for. + + permission_item : list[PermissionsRule] + The permissions to apply to the datasource item. + + Returns + ------- + None + """ self._permissions.update(item, permission_item) @api(version="2.0") def delete_permission(self, item: DatasourceItem, capability_item: "PermissionsRule") -> None: + """ + Deletes a single permission rule from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#delete_data_source_permissionDatasourceItem + + Parameters + ---------- + item : DatasourceItem + The datasource item to delete permissions from. + + capability_item : PermissionsRule + The permission rule to delete. + + Returns + ------- + None + """ self._permissions.delete(item, capability_item) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item) -> None: + """ + Get information about the data quality warning for the database, table, + column, published data source, flow, virtual connection, or virtual + connection table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_dqws + + Parameters + ---------- + item : DatasourceItem + The datasource item to populate data quality warnings for. + + Returns + ------- + None + """ self._data_quality_warnings.populate(item) @api(version="3.5") def update_dqw(self, item, warning): + """ + Update the warning type, status, and message of a data quality warning. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_dqw + + Parameters + ---------- + item : DatasourceItem + The datasource item to update data quality warnings for. + + warning : DQWItem + The data quality warning to update. + + Returns + ------- + DQWItem + The updated data quality warning. + """ return self._data_quality_warnings.update(item, warning) @api(version="3.5") def add_dqw(self, item, warning): + """ + Add a data quality warning to a datasource. + + The Add Data Quality Warning method adds a data quality warning to an + asset. (An automatically-generated monitoring warning does not count + towards this limit.) In Tableau Cloud February 2024 and Tableau Server + 2024.2 and earlier, adding a data quality warning to an asset that + already has one causes an error. + + This method is available if your Tableau Cloud site or Tableau Server is licensed with Data Management. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#add_dqw + + Parameters + ---------- + item: DatasourceItem + The datasource item to add data quality warnings to. + + warning: DQWItem + The data quality warning to add. + + Returns + ------- + DQWItem + The added data quality warning. + + """ return self._data_quality_warnings.add(item, warning) @api(version="3.5") def delete_dqw(self, item): + """ + Delete a data quality warnings from an asset. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#delete_dqws + + Parameters + ---------- + item: DatasourceItem + The datasource item to delete data quality warnings from. + + Returns + ------- + None + """ self._data_quality_warnings.clear(item) # Populate datasource item's revisions @api(version="2.3") def populate_revisions(self, datasource_item: DatasourceItem) -> None: + """ + Retrieve revision information for the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#get_data_source_revisions + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to retrieve revisions for. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the datasource item is missing an ID. + """ if not datasource_item.id: error = "Datasource item missing ID. Datasource must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -413,6 +853,35 @@ def download_revision( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: + """ + Downloads a specific version of a data source prior to the current one + in .tdsx format. To download the current version of a data source set + the revision number to None. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#download_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to download. + + revision_number : Optional[str] + The revision number of the data source to download. To determine + what versions are available, call the `populate_revisions` method. + Pass None to download the current version. + + filepath : Optional[PathOrFileW] + The file path to save the downloaded datasource to. If not + specified, the file will be saved to the current working directory. + + include_extract : bool, default True + If True, the extract is included in the download. If False, the + extract is not included. + + Returns + ------- + filepath : PathOrFileW + """ if not datasource_id: error = "Datasource ID undefined." raise ValueError(error) @@ -446,6 +915,28 @@ def download_revision( @api(version="2.3") def delete_revision(self, datasource_id: str, revision_number: str) -> None: + """ + Removes a specific version of a data source from the specified site. + + The content is removed, and the specified revision can no longer be + downloaded using Download Data Source Revision. If you call Get Data + Source Revisions, the revision that's been removed is listed with the + attribute is_deleted=True. + + REST API:https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#remove_data_source_revision + + Parameters + ---------- + datasource_id : str + The unique ID of the datasource to delete. + + revision_number : str + The revision number of the data source to delete. + + Returns + ------- + None + """ if datasource_id is None or revision_number is None: raise ValueError url = "/".join([self.baseurl, datasource_id, "revisions", revision_number]) @@ -458,18 +949,83 @@ def delete_revision(self, datasource_id: str, revision_number: str) -> None: def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> list["AddResponse"]: # actually should return a task + """ + Adds a task to refresh a data source to an existing server schedule on + Tableau Server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + + Parameters + ---------- + schedule_id : str + The unique ID of the schedule to add the task to. + + item : DatasourceItem + The datasource item to add to the schedule. + + Returns + ------- + list[AddResponse] + """ return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + """ + Adds one or more tags to the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#add_tags_to_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to add tags to. + + tags : Union[Iterable[str], str] + The tag or tags to add to the datasource item. + + Returns + ------- + set[str] + The updated set of tags on the datasource item. + """ return super().add_tags(item, tags) @api(version="1.0") def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + """ + Deletes one or more tags from the specified datasource item. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_tag_from_data_source + + Parameters + ---------- + item : Union[DatasourceItem, str] + The datasource item or ID to delete tags from. + + tags : Union[Iterable[str], str] + The tag or tags to delete from the datasource item. + + Returns + ------- + None + """ return super().delete_tags(item, tags) @api(version="1.0") def update_tags(self, item: DatasourceItem) -> None: + """ + Updates the tags on the server to match the specified datasource item. + + Parameters + ---------- + item : DatasourceItem + The datasource item to update tags for. + + Returns + ------- + None + """ return super().update_tags(item) def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: From f17e8e77af78f49e2e7ac2472571d0d39599ff25 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:12 -0500 Subject: [PATCH 553/567] fix: change GroupSets.get api for more consistency (#1568) All other endpoints accept RequestOptions as an argument named req_options. This PR makes GroupSets more consistent with other endpoints. This makes it work with the Pager option now. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/endpoint/groupsets_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index c7f5ed0e5..8c0ef64f3 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -25,14 +25,14 @@ def baseurl(self) -> str: @api(version="3.22") def get( self, - request_options: Optional[RequestOptions] = None, + req_options: Optional[RequestOptions] = None, result_level: Optional[Literal["members", "local"]] = None, ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl if result_level: url += f"?resultlevel={result_level}" - server_response = self.get_request(url, request_options) + server_response = self.get_request(url, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_set_items, pagination_item From e39dbcb3fecfaf3f8191c7fdbc32de838ad6968e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:38 -0500 Subject: [PATCH 554/567] fix: virtual connection ConnectionItem attributes (#1566) Closes #1558 Connection XML element for VirtualConnections has different attribute keys compared to connection XML elements when returned by Datasources, Workbooks, and Flows. This PR adds in flexibility to ConnectionItem's reading of XML to account for both sets of attributes that may be present elements. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/connection_item.py | 6 +-- ...rtual_connection_populate_connections2.xml | 6 +++ test/test_virtual_connection.py | 40 +++++++++++-------- 3 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 test/assets/virtual_connection_populate_connections2.xml diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index e68958c3b..6a8244fb1 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -103,11 +103,11 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: all_connection_xml = parsed_response.findall(".//t:connection", namespaces=ns) for connection_xml in all_connection_xml: connection_item = cls() - connection_item._id = connection_xml.get("id", None) + connection_item._id = connection_xml.get("id", connection_xml.get("connectionId", None)) connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) - connection_item.server_address = connection_xml.get("serverAddress", None) - connection_item.server_port = connection_xml.get("serverPort", None) + connection_item.server_address = connection_xml.get("serverAddress", connection_xml.get("server", None)) + connection_item.server_port = connection_xml.get("serverPort", connection_xml.get("port", None)) connection_item.username = connection_xml.get("userName", None) connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None diff --git a/test/assets/virtual_connection_populate_connections2.xml b/test/assets/virtual_connection_populate_connections2.xml new file mode 100644 index 000000000..f0ad2646d --- /dev/null +++ b/test/assets/virtual_connection_populate_connections2.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py index 975033d2d..5d9a2d1bc 100644 --- a/test/test_virtual_connection.py +++ b/test/test_virtual_connection.py @@ -2,6 +2,7 @@ from pathlib import Path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -12,6 +13,7 @@ VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml" VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" @@ -54,23 +56,27 @@ def test_virtual_connection_get(self): assert items[0].name == "vconn" def test_virtual_connection_populate_connections(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) - vc_out = self.server.virtual_connections.populate_connections(vconn) - connection_list = list(vconn.connections) - - assert vc_out is vconn - assert vc_out._connections is not None - - assert len(connection_list) == 1 - connection = connection_list[0] - assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - assert connection.connection_type == "postgres" - assert connection.server_address == "localhost" - assert connection.server_port == "5432" - assert connection.username == "pgadmin" + for i, populate_connections_xml in enumerate( + (VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2) + ): + with self.subTest(i): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" def test_virtual_connection_update_connection_db_connection(self): vconn = VirtualConnectionItem("vconn") From ba716b9d5d08977306a3d463f2c07e32077df02e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Mar 2025 17:21:52 -0500 Subject: [PATCH 555/567] fix: remove vizHeight and vizWidth from ImageRequestOptions (#1565) feat: properly support request option filters Many filters and RequestOptions added in 3.23. Adds explicit support for them and checks for prior versions. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + tableauserverclient/server/__init__.py | 2 + .../server/endpoint/exceptions.py | 4 ++ .../server/endpoint/views_endpoint.py | 6 ++- .../server/endpoint/workbooks_endpoint.py | 32 ++++++++---- tableauserverclient/server/request_options.py | 34 +++++++++++++ test/test_view.py | 38 ++++++++++++++ test/test_workbook.py | 51 +++++++++++++++++-- 8 files changed, 156 insertions(+), 13 deletions(-) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 39f8267a8..957a820db 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -56,6 +56,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, MissingRequiredFieldError, FailedSignInError, @@ -107,6 +108,7 @@ "Pager", "PaginationItem", "PDFRequestOptions", + "PPTXRequestOptions", "Permission", "PermissionsRule", "PersonalAccessTokenAuth", diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 87cc9460b..55288fdc9 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,6 +5,7 @@ ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, + PPTXRequestOptions, RequestOptions, ) from tableauserverclient.server.filter import Filter @@ -52,6 +53,7 @@ "ExcelRequestOptions", "ImageRequestOptions", "PDFRequestOptions", + "PPTXRequestOptions", "RequestOptions", "Filter", "Sort", diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 77332da3e..ee931c910 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -113,3 +113,7 @@ def __str__(self): class FlowRunCancelledException(FlowRunFailedException): pass + + +class UnsupportedAttributeError(TableauError): + pass diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 12b386876..9d1c8b00f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -3,7 +3,7 @@ from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api -from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, UnsupportedAttributeError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server.query import QuerySet @@ -171,6 +171,10 @@ 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+") + 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 4fdcf075b..8507152ba 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,7 +11,11 @@ from tableauserverclient.server.query import QuerySet from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in -from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + InternalServerError, + MissingRequiredFieldError, + UnsupportedAttributeError, +) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin @@ -34,7 +38,7 @@ if TYPE_CHECKING: from tableauserverclient.server import Server - from tableauserverclient.server.request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions, PDFRequestOptions, PPTXRequestOptions from tableauserverclient.models import DatasourceItem from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse @@ -472,11 +476,12 @@ def _get_workbook_connections( connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) return connections - # Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its tabs are disabled @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"] = None) -> None: """ - Populates the PDF for the specified workbook item. + Populates the PDF for the specified workbook item. Get the pdf of the + entire workbook if its tabs are enabled, pdf of the default view if its + tabs are disabled. This method populates a PDF with image(s) of the workbook view(s) you specify. @@ -488,7 +493,7 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque workbook_item : WorkbookItem The workbook item to populate the PDF for. - req_options : RequestOptions, optional + req_options : PDFRequestOptions, optional (Optional) You can pass in request options to specify the page type and orientation of the PDF content, as well as the maximum age of the PDF rendered on the server. See PDFRequestOptions class for more @@ -510,17 +515,26 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["Reque def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) + if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: + if req_options.view_filters or req_options.view_parameters: + raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 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+") + workbook_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") - def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") - def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_powerpoint( + self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"] = None + ) -> None: """ Populates the PowerPoint for the specified workbook item. @@ -561,7 +575,7 @@ def pptx_fetcher() -> bytes: workbook_item._set_powerpoint(pptx_fetcher) logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") - def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"]) -> bytes: + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"]) -> bytes: url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index c37c0ce42..504f7f3ca 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -385,6 +385,8 @@ class PDFRequestOptions(_ImagePDFCommonExportOptions): Options that can be used when exporting a view to PDF. Set the maxage to control the age of the data exported. Filters to the underlying data can be applied using the `vf` and `parameter` methods. + vf and parameter filters are only supported in API version 3.23 and later. + Parameters ---------- page_type: str, optional @@ -438,3 +440,35 @@ def get_query_params(self) -> dict: params["orientation"] = self.orientation return params + + +class PPTXRequestOptions(RequestOptionsBase): + """ + Options that can be used when exporting a view to PPTX. Set the maxage to control the age of the data exported. + + Parameters + ---------- + maxage: int, optional + The maximum age of the data to export. Shortest possible duration is 1 + minute. No upper limit. Default is -1, which means no limit. + """ + + def __init__(self, maxage=-1): + super().__init__() + self.max_age = maxage + + @property + def max_age(self) -> int: + return self._max_age + + @max_age.setter + @property_is_int(range=(0, 240), allowed=[-1]) + def max_age(self, value): + self._max_age = value + + def get_query_params(self): + params = {} + if self.max_age != -1: + params["maxAge"] = self.max_age + + return params diff --git a/test/test_view.py b/test/test_view.py index a89a6d235..3fdaf60e6 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -6,6 +6,7 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -177,6 +178,43 @@ def test_populate_image(self) -> None: self.server.views.populate_image(single_view) self.assertEqual(response, single_view.image) + def test_populate_image_unsupported(self) -> None: + self.server.version = "3.8" + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + with self.assertRaises(UnsupportedAttributeError): + self.server.views.populate_image(single_view, req_option) + + def test_populate_image_viz_dimensions(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.views.baseurl + with open(POPULATE_PREVIEW_IMAGE, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + self.server.views.populate_image(single_view, req_option) + self.assertEqual(response, single_view.image) + + history = m.request_history + def test_populate_image_with_options(self) -> None: with open(POPULATE_PREVIEW_IMAGE, "rb") as f: response = f.read() diff --git a/test/test_workbook.py b/test/test_workbook.py index 0aa52f50d..f3c2dd147 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -12,7 +12,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule -from tableauserverclient.server.endpoint.exceptions import InternalServerError +from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory from ._utils import asset @@ -450,6 +450,49 @@ def test_populate_pdf(self) -> None: self.server.workbooks.populate_pdf(single_workbook, req_option) self.assertEqual(response, single_workbook.pdf) + def test_populate_pdf_unsupported(self) -> None: + self.server.version = "3.4" + self.baseurl = self.server.workbooks.baseurl + with requests_mock.mock() as m: + m.get( + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=b"", + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + + with self.assertRaises(UnsupportedAttributeError): + self.server.workbooks.populate_pdf(single_workbook, req_option) + + def test_populate_pdf_vf_dims(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.workbooks.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape&vf_Region=West&vizWidth=1920&vizHeight=1080", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + req_option.viz_width = 1920 + req_option.viz_height = 1080 + + self.server.workbooks.populate_pdf(single_workbook, req_option) + self.assertEqual(response, single_workbook.pdf) + def test_populate_powerpoint(self) -> None: self.server.version = "3.8" self.baseurl = self.server.workbooks.baseurl @@ -457,13 +500,15 @@ def test_populate_powerpoint(self) -> None: response = f.read() with requests_mock.mock() as m: m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint", + self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint?maxAge=1", content=response, ) single_workbook = TSC.WorkbookItem("test") single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_powerpoint(single_workbook) + ro = TSC.PPTXRequestOptions(maxage=1) + + self.server.workbooks.populate_powerpoint(single_workbook, ro) self.assertEqual(response, single_workbook.powerpoint) def test_populate_preview_image(self) -> None: From b81993a1675c140685920b917cd0d00f84fe8873 Mon Sep 17 00:00:00 2001 From: renoyjohnm <168143499+renoyjohnm@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:48:54 -0700 Subject: [PATCH 556/567] Adding incremental refresh option for workbook and datasource endpoints along with the new JobItem code changes (#1585) * Adding incremental refresh option to workbook and datasource along with new job item finish code * Fix build pipeline failures related to mypy & black --- tableauserverclient/models/job_item.py | 1 + .../server/endpoint/datasources_endpoint.py | 3 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 16 +++++-- test/assets/job_get_by_id_completed.xml | 14 ++++++ test/request_factory/test_task_requests.py | 48 +++++++++++++++++++ test/test_job.py | 12 +++++ 8 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 test/assets/job_get_by_id_completed.xml create mode 100644 test/request_factory/test_task_requests.py diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 6286275c5..d650eb846 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -82,6 +82,7 @@ class FinishCode: Success: int = 0 Failed: int = 1 Cancelled: int = 2 + Completed: int = 3 def __init__( self, diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index e50a74ecb..69913a724 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -340,8 +340,7 @@ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> """ id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" - # refresh_req = RequestFactory.Task.refresh_req(incremental) - refresh_req = RequestFactory.Empty.empty_req() + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 027a7ca12..48e91bd74 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -188,7 +188,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}") - if job.finish_code == JobItem.FinishCode.Success: + if job.finish_code in [JobItem.FinishCode.Success, JobItem.FinishCode.Completed]: return job elif job.finish_code == JobItem.FinishCode.Failed: raise JobFailedException(job) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8507152ba..bf4088b9f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -140,7 +140,7 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" - refresh_req = RequestFactory.Task.refresh_req(incremental) + refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) server_response = self.post_request(url, refresh_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 79ac6e4ca..575423612 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1118,11 +1118,17 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: pass @_tsrequest_wrapped - def refresh_req(self, xml_request: ET.Element, incremental: bool = False) -> bytes: - task_element = ET.SubElement(xml_request, "extractRefresh") - if incremental: - task_element.attrib["incremental"] = "true" - return ET.tostring(xml_request) + def refresh_req( + self, xml_request: ET.Element, incremental: bool = False, parent_srv: Optional["Server"] = None + ) -> Optional[bytes]: + if parent_srv is not None and parent_srv.check_at_least_version("3.25"): + task_element = ET.SubElement(xml_request, "extractRefresh") + if incremental: + task_element.attrib["incremental"] = "true" + return ET.tostring(xml_request) + elif incremental: + raise ValueError("Incremental refresh is only supported in 3.25+") + return None @_tsrequest_wrapped def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: diff --git a/test/assets/job_get_by_id_completed.xml b/test/assets/job_get_by_id_completed.xml new file mode 100644 index 000000000..95ca29b49 --- /dev/null +++ b/test/assets/job_get_by_id_completed.xml @@ -0,0 +1,14 @@ + + + + + Job detail notes + + + More detail + + + \ No newline at end of file diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py new file mode 100644 index 000000000..0258b8a93 --- /dev/null +++ b/test/request_factory/test_task_requests.py @@ -0,0 +1,48 @@ +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import Mock +from tableauserverclient.server.request_factory import TaskRequest + + +class TestTaskRequest(unittest.TestCase): + + def setUp(self): + self.task_request = TaskRequest() + self.xml_request = ET.Element("tsRequest") + + def test_refresh_req_default(self): + result = self.task_request.refresh_req() + self.assertEqual(result, ET.tostring(self.xml_request)) + + def test_refresh_req_incremental(self): + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True) + + def test_refresh_req_with_parent_srv_version_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + task_element = ET.SubElement(expected_xml, "extractRefresh") + task_element.attrib["incremental"] = "true" + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + ET.SubElement(expected_xml, "extractRefresh") + self.assertEqual(result, ET.tostring(expected_xml)) + + def test_refresh_req_with_parent_srv_version_below_3_25(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + with self.assertRaises(ValueError): + self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) + + def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental(self): + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) + self.assertEqual(result, ET.tostring(self.xml_request)) diff --git a/test/test_job.py b/test/test_job.py index 20b238764..b3d7007aa 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -11,6 +11,7 @@ GET_XML = "job_get.xml" GET_BY_ID_XML = "job_get_by_id.xml" +GET_BY_ID_COMPLETED_XML = "job_get_by_id_completed.xml" GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" @@ -87,6 +88,17 @@ def test_wait_for_job_finished(self) -> None: self.assertEqual(job_id, job.id) self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_completed(self) -> None: + # Waiting for a bridge (cloud) job completion + response_xml = read_xml_asset(GET_BY_ID_COMPLETED_XML) + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.wait_for_job(job_id) + + self.assertEqual(job_id, job.id) + self.assertListEqual(job.notes, ["Job detail notes"]) + def test_wait_for_job_failed(self) -> None: # Waiting for a failed job raises an exception response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) From d7935704a0bcaac595d26eb8bf4da39ffb1d4fb5 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 21 Apr 2025 23:24:40 -0500 Subject: [PATCH 557/567] ci: update slack action vm version Switch Slack action to use `ubuntu-latest` like our other actions. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .github/workflows/slack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 2ecb0be7f..9afebf25b 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -5,7 +5,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API From dbd0c0f1f0a76c04f5b36b79f14ccfb15459082c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 22 Apr 2025 10:00:44 -0500 Subject: [PATCH 558/567] feat: enable retrieving only owned workbooks Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/users_endpoint.py | 25 ++++++++++++++++--- test/test_user.py | 16 ++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..75c7bd2ed 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -381,10 +381,15 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_workbooks( + self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + ) -> None: """ Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. + or has Read (view) permissions for. If owned_only is set to True, + only the workbooks that the user owns are returned. If owned_only is + set to False, all workbooks that the user has Read (view) permissions + for are returned. This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for @@ -402,6 +407,10 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO req_options : Optional[RequestOptions] Optional request options to filter and sort the results. + owned_only : bool, default=False + If True, only the workbooks that the user owns are returned. + If False, all workbooks that the user has Read (view) permissions + Returns ------- None @@ -423,14 +432,22 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO raise MissingRequiredFieldError(error) def wb_pager(): - return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options) + def func(req_options): + return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only) + + return Pager(func, req_options) user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, + user_item: UserItem, + req_options: Optional[RequestOptions] = None, + owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" + if owned_only: + url += "?ownedBy=true" server_response = self.get_request(url, req_options) logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/test/test_user.py b/test/test_user.py index a46624845..645adcfd5 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -162,6 +162,22 @@ def test_populate_workbooks(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + def test_populate_owned_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) From a5ea3338086d74e355cd5a3d0b79f3fe4160a4a2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 14:24:23 -0700 Subject: [PATCH 559/567] docs: docstrings for schedules and intervals (#1528) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/interval_item.py | 40 ++++++ tableauserverclient/models/schedule_item.py | 57 +++++++++ .../server/endpoint/schedules_endpoint.py | 114 +++++++++++++++++- 3 files changed, 210 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..14cec1878 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -2,6 +2,13 @@ class IntervalItem: + """ + This class sets the frequency and start time of the scheduled item. This + class contains the classes for the hourly, daily, weekly, and monthly + intervals. This class mirrors the options you can set using the REST API and + the Tableau Server interface. + """ + class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -26,6 +33,19 @@ class Day: class HourlyInterval: + """ + Runs scheduled item hourly. To set the hourly interval, you create an + instance of the HourlyInterval class and assign the following values: + start_time, end_time, and interval_value. To set the start_time and + end_time, assign the time value using this syntax: start_time=time(hour, minute) + and end_time=time(hour, minute). The hour is specified in 24 hour time. + The interval_value specifies how often the to run the task within the + start and end time. The options are expressed in hours. For example, + interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6, + 8, 12. Hourly schedules that run more frequently than every 60 minutes must + have start and end times that are on the hour. + """ + def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -109,6 +129,12 @@ def _interval_type_pairs(self): class DailyInterval: + """ + Runs the scheduled item daily. To set the daily interval, you create an + instance of the DailyInterval and assign the start_time. The start time uses + the syntax start_time=time(hour, minute). + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -177,6 +203,15 @@ def _interval_type_pairs(self): class WeeklyInterval: + """ + Runs the scheduled item once a week. To set the weekly interval, you create + an instance of the WeeklyInterval and assign the start time and multiple + instances for the interval_value (days of week and start time). The start + time uses the syntax time(hour, minute). The interval_value is the day of + the week, expressed as a IntervalItem. For example + TSC.IntervalItem.Day.Monday for Monday. + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -214,6 +249,11 @@ def _interval_type_pairs(self): class MonthlyInterval: + """ + Runs the scheduled item once a month. To set the monthly interval, you + create an instance of the MonthlyInterval and assign the start time and day. + """ + def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..a2118e3d6 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -20,6 +20,63 @@ class ScheduleItem: + """ + Using the TSC library, you can schedule extract refresh or subscription + tasks on Tableau Server. You can also get and update information about the + scheduled tasks, or delete scheduled tasks. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The schedule properties are defined in the ScheduleItem class. The class + corresponds to the properties for schedules you can access in Tableau + Server or by using the Tableau Server REST API. The Schedule methods are + based upon the endpoints for jobs in the REST API and operate on the JobItem + class. + + Parameters + ---------- + name : str + The name of the schedule. + + priority : int + The priority of the schedule. Lower values represent higher priority, + with 0 indicating the highest priority. + + schedule_type : str + The type of task schedule. See ScheduleItem.Type for the possible values. + + execution_order : str + Specifies how the scheduled tasks should run. The choices are Parallel + which uses all avaiable background processes for a scheduled task, or + Serial, which limits the schedule to one background process. + + interval_item : Interval + Specifies the frequency that the scheduled task should run. The + interval_item is an instance of the IntervalItem class. The + interval_item has properties for frequency (hourly, daily, weekly, + monthly), and what time and date the scheduled item runs. You set this + value by declaring an IntervalItem object that is one of the following: + HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval. + + Attributes + ---------- + created_at : datetime + The date and time the schedule was created. + + end_schedule_at : datetime + The date and time the schedule ends. + + id : str + The unique identifier for the schedule. + + next_run_at : datetime + The date and time the schedule is next run. + + state : str + The state of the schedule. See ScheduleItem.State for the possible values. + """ + class Type: Extract = "Extract" Flow = "Flow" diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..8693d66cc 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -30,6 +30,23 @@ def siteurl(self) -> str: @api(version="2.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + """ + Returns a list of flows, extract, and subscription server schedules on + Tableau Server. For each schedule, the API returns name, frequency, + priority, and other information. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filtering and paginating options for request. + + Returns + ------- + Tuple[List[ScheduleItem], PaginationItem] + A tuple of list of ScheduleItem and PaginationItem + """ logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,7 +55,22 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche return all_schedule_items, pagination_item @api(version="3.8") - def get_by_id(self, schedule_id): + def get_by_id(self, schedule_id: str) -> ScheduleItem: + """ + Returns detailed information about the specified server schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to get information for. + + Returns + ------- + ScheduleItem + The schedule item that corresponds to the given ID. + """ if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) @@ -49,6 +81,20 @@ def get_by_id(self, schedule_id): @api(version="2.3") def delete(self, schedule_id: str) -> None: + """ + Deletes the specified schedule from the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to delete. + + Returns + ------- + None + """ if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -58,6 +104,23 @@ def delete(self, schedule_id: str) -> None: @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Modifies settings for the specified server schedule, including the name, + priority, and frequency details on Tableau Server. For Tableau Cloud, + see the tasks and subscritpions API. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to update. + + Returns + ------- + ScheduleItem + The updated schedule item. + """ if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -71,6 +134,20 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @api(version="2.3") def create(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Creates a new server schedule on Tableau Server. For Tableau Cloud, use + the tasks and subscriptions API. + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to create. + + Returns + ------- + ScheduleItem + The newly created schedule. + """ if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -92,6 +169,41 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> list[AddResponse]: + """ + Adds a workbook, datasource, or flow to a schedule on Tableau Server. + Only one of workbook, datasource, or flow can be passed in at a time. + + The task type is optional and will default to ExtractRefresh if a + workbook or datasource is passed in, and RunFlow if a flow is passed in. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to add the item to. + + workbook : Optional[WorkbookItem] + The workbook to add to the schedule. + + datasource : Optional[DatasourceItem] + The datasource to add to the schedule. + + flow : Optional[FlowItem] + The flow to add to the schedule. + + task_type : Optional[str] + The type of task to add to the schedule. If not provided, it will + default to ExtractRefresh if a workbook or datasource is passed in, + and RunFlow if a flow is passed in. + + Returns + ------- + list[AddResponse] + A list of responses for each item added to the schedule. + """ # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) From a2b1558dd21032ac10c5e6871f62b3c151c89140 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 16:33:51 -0700 Subject: [PATCH 560/567] Add support for multiple IDPs (jorwoods) Add support for multiple IDPs Fixes #1574 Fixes #1598 --------- Authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 ++ tableauserverclient/models/__init__.py | 3 +- tableauserverclient/models/site_item.py | 28 +++++++++++++++ tableauserverclient/models/user_item.py | 25 ++++++++++++- .../server/endpoint/sites_endpoint.py | 19 +++++++++- tableauserverclient/server/request_factory.py | 5 +++ test/assets/site_auth_configurations.xml | 18 ++++++++++ test/test_site.py | 26 ++++++++++++++ test/test_user.py | 36 +++++++++++++++++++ 9 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 test/assets/site_auth_configurations.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..538f85221 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -35,6 +35,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -121,6 +122,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..10c3149f1 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -35,7 +35,7 @@ from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.schedule_item import ScheduleItem from tableauserverclient.models.server_info_item import ServerInfoItem -from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -83,6 +83,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..5f6702b80 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -94,6 +95,7 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +186,18 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +218,9 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None) return self def _set_values( @@ -219,6 +234,7 @@ def _set_values( email, auth_setting, domain_name, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +254,8 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -265,6 +283,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +296,7 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +315,7 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +332,7 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + idp_configuration_id, ) class CSVImport: @@ -361,6 +383,7 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, ) return user diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 575423612..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,28 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None diff --git a/test/test_user.py b/test/test_user.py index 645adcfd5..e258fa938 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,6 +1,7 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC @@ -249,3 +250,38 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_add_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" From 5e112bba3f701fdae98233205fe3fe988a0ada91 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 17:07:02 -0700 Subject: [PATCH 561/567] feat: Add fields:_all_ support (#1563) * feat: project support all fields * feat: groups all fields * feat: views support all fields * feat: user support _all_ fields * feat: workbook support all fields * feat: datasourceitem _all_ fields * feat: add fields methods to QuerySet * docs: Docstrings for new fields * feat: add owner attribute to project * fix: restore _all_fields but deprecated --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + tableauserverclient/helpers/strings.py | 26 ++- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/datasource_item.py | 108 ++++++++++++ tableauserverclient/models/group_item.py | 11 ++ tableauserverclient/models/location_item.py | 53 ++++++ tableauserverclient/models/project_item.py | 165 +++++++++++++++--- tableauserverclient/models/user_item.py | 79 ++++++++- tableauserverclient/models/view_item.py | 84 ++++++++- tableauserverclient/models/workbook_item.py | 152 +++++++++++++++- .../server/endpoint/endpoint.py | 39 +++++ .../server/endpoint/users_endpoint.py | 2 +- tableauserverclient/server/query.py | 36 ++++ tableauserverclient/server/request_options.py | 130 +++++++++++++- test/assets/datasource_get_all_fields.xml | 10 ++ test/assets/group_get_all_fields.xml | 14 ++ test/assets/project_get_all_fields.xml | 9 + test/assets/user_get_all_fields.xml | 11 ++ test/assets/view_get_all_fields.xml | 35 ++++ test/assets/workbook_get_all_fields.xml | 46 +++++ test/test_datasource.py | 39 ++++- test/test_group.py | 23 +++ test/test_project.py | 26 +++ test/test_request_option.py | 12 +- test/test_user.py | 37 +++- test/test_view.py | 116 +++++++++++- test/test_workbook.py | 106 ++++++++++- 27 files changed, 1330 insertions(+), 43 deletions(-) create mode 100644 tableauserverclient/models/location_item.py create mode 100644 test/assets/datasource_get_all_fields.xml create mode 100644 test/assets/group_get_all_fields.xml create mode 100644 test/assets/project_get_all_fields.xml create mode 100644 test/assets/user_get_all_fields.xml create mode 100644 test/assets/view_get_all_fields.xml create mode 100644 test/assets/workbook_get_all_fields.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 538f85221..21e2c4760 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -102,6 +103,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 10c3149f1..746bb24dd 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -75,6 +76,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2005edf7e..de976f359 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,6 +18,7 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: @@ -40,6 +43,9 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + connections : list[ConnectionItem] The list of data connections (ConnectionItem) for the specified data source. You must first call the populate_connections method to access @@ -67,6 +73,12 @@ class DatasourceItem: A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + has_extracts : Optional[bool] A Boolean value that indicates whether the datasource has extracts. @@ -75,13 +87,22 @@ class DatasourceItem: specific data source or to delete a data source with the get_by_id and delete methods. + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + name : Optional[str] The name of the data source. If not specified, the name of the published data source file is used. + owner: Optional[UserItem] + The owner of the data source. + owner_id : Optional[str] The identifier of the owner of the data source. + project : Optional[ProjectItem] + The project that the data source belongs to. + project_id : Optional[str] The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. @@ -89,6 +110,9 @@ class DatasourceItem: project_name : Optional[str] The name of the project associated with the data source. + server_name : Optional[str] + The name of the server where the data source is published. + tags : Optional[set[str]] The tags (list of strings) that have been added to the data source. @@ -143,6 +167,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.owner_id: Optional[str] = None self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -274,6 +305,34 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def _set_connections(self, connections) -> None: self._connections = connections @@ -310,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -331,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -355,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -394,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -428,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -438,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -471,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 5f6702b80..c995b4e07 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -38,6 +38,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -95,6 +138,8 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None self._idp_configuration_id: Optional[str] = None return None @@ -186,6 +231,14 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + @property def idp_configuration_id(self) -> Optional[str]: """ @@ -219,8 +272,10 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": auth_setting, _, _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -234,6 +289,8 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ): if id is not None: @@ -254,6 +311,10 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language if idp_configuration_id: self._idp_configuration_id = idp_configuration_id @@ -267,6 +328,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -283,6 +350,8 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) @@ -296,6 +365,8 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) all_user_items.append(user_item) @@ -315,6 +386,8 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None @@ -332,6 +405,8 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, idp_configuration_id, ) @@ -384,6 +459,8 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.AUTH], None, None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) - tags_elem = view_xml.find(".//t:tags", namespaces=ns) + tags_elem = view_xml.find("./t:tags", namespaces=ns) data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location if data_acceleration_config_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) view_item.data_acceleration_config = data_acceleration_config @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 75c7bd2ed..17af21a03 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 504f7f3ca..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index b7e7e2721..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_user.py b/test/test_user.py index e258fa938..fa2ac3a12 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -5,11 +5,12 @@ import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -251,6 +252,40 @@ def test_get_users_from_file(self): assert users[0].name == "Cassie", users assert failures == [] + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + def test_add_user_idp_configuration(self) -> None: with open(ADD_XML) as f: response_xml = f.read() diff --git a/test/test_view.py b/test/test_view.py index 3fdaf60e6..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,13 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") @@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_workbook.py b/test/test_workbook.py index f3c2dd147..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,7 +10,7 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory @@ -24,6 +24,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml") ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") @@ -978,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator" From 823fe69f4d502e303201e76f6d5703977be45d62 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 May 2025 17:30:27 -0700 Subject: [PATCH 562/567] Add SSL option for connecting to Tableau Server with a weaker DH key length (#1596) * Add SSL option for connecting to Tableau Server with a weaker DH key length Fixes #1582 --- tableauserverclient/server/server.py | 42 +++++++++++++++ test/test_ssl_config.py | 77 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/test_ssl_config.py diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("https://") and not server_address.startswith("https://"): @@ -313,3 +332,26 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({"verify": self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server("http://test") + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options["verify"], mock_context) + + @patch("ssl.create_default_context") + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn("verify", self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level="WARNING") as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", + ) + + +if __name__ == "__main__": + unittest.main() From d720b1bb0ea7447ca6bf06e8cb4423d587fe2034 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 13 May 2025 17:32:30 -0700 Subject: [PATCH 563/567] chore: type hint database and table objects (#1593) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 4 +- tableauserverclient/models/flow_item.py | 4 +- tableauserverclient/models/table_item.py | 10 +- tableauserverclient/models/tableau_types.py | 14 +- .../server/endpoint/databases_endpoint.py | 119 +++++++++++-- .../server/endpoint/datasources_endpoint.py | 6 +- .../server/endpoint/dqw_endpoint.py | 22 ++- .../server/endpoint/tables_endpoint.py | 157 ++++++++++++++++-- 8 files changed, 284 insertions(+), 52 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index de976f359..5501ee332 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -339,8 +339,8 @@ def _set_connections(self, connections) -> None: def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _set_revisions(self, revisions): self._revisions = revisions diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0083776bb..063897e41 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -146,8 +146,8 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..541f84360 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,8 +1,12 @@ +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_boolean +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem + class TableItem: def __init__(self, name, description=None): @@ -40,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property @@ -100,8 +104,8 @@ def columns(self): def _set_columns(self, columns): self._columns = columns - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None: + self._data_quality_warnings = dqw def _set_values(self, table_values): if "id" in table_values: diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..e69d02a06 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,8 +1,10 @@ from typing import Union +from tableauserverclient.models.database_item import DatabaseItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem from tableauserverclient.models.metric_item import MetricItem @@ -25,7 +27,17 @@ class Resource: # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] +TableauItem = Union[ + DatasourceItem, + FlowItem, + MetricItem, + ProjectItem, + ViewItem, + WorkbookItem, + VirtualConnectionItem, + DatabaseItem, + TableItem, +] def plural_type(content_type: Union[Resource, str]) -> str: diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..dc88ceaa5 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -13,6 +14,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.dqw_item import DQWItem + from tableauserverclient.server.request_options import RequestOptions + class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): @@ -23,11 +28,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + """ + Get information about all databases on the site. Endpoint is paginated, + and will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[DatabaseItem], PaginationItem] + A tuple containing a list of DatabaseItem objects and a + PaginationItem object. + """ logger.info("Querying all databases on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -37,7 +60,27 @@ def get(self, req_options=None): # Get 1 database @api(version="3.5") - def get_by_id(self, database_id): + def get_by_id(self, database_id: str) -> DatabaseItem: + """ + Get information about a single database asset on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database + + Parameters + ---------- + database_id : str + The ID of the database to retrieve. + + Returns + ------- + DatabaseItem + A DatabaseItem object representing the database. + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "database ID undefined." raise ValueError(error) @@ -47,7 +90,24 @@ def get_by_id(self, database_id): return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, database_id): + def delete(self, database_id: str) -> None: + """ + Deletes a single database asset from the server. + + Parameters + ---------- + database_id : str + The ID of the database to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "Database ID undefined." raise ValueError(error) @@ -56,7 +116,28 @@ def delete(self, database_id): logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") - def update(self, database_item): + def update(self, database_item: DatabaseItem) -> DatabaseItem: + """ + Update the database description, certify the database, set permissions, + or assign a User as the database contact. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database + + Parameters + ---------- + database_item : DatabaseItem + The DatabaseItem object to update. + + Returns + ------- + DatabaseItem + The updated DatabaseItem object. + + Raises + ------ + MissingRequiredFieldError + If the database item is missing an ID. + """ if not database_item.id: error = "Database item missing ID." raise MissingRequiredFieldError(error) @@ -88,43 +169,45 @@ def _get_tables_for_database(self, database_item): return tables @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: DatabaseItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="3.5") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: DatabaseItem): self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") - def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Resource.Table) + def update_table_default_permissions( + self, item: DatabaseItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permission(item, Resource.Table) + def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 69913a724..168446974 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -733,7 +733,7 @@ def populate_dqw(self, item) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Update the warning type, status, and message of a data quality warning. @@ -755,7 +755,7 @@ def update_dqw(self, item, warning): return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Add a data quality warning to a datasource. @@ -786,7 +786,7 @@ def add_dqw(self, item, warning): return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatasourceItem) -> None: """ Delete a data quality warnings from an asset. diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..d2ad517ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Optional, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -7,6 +8,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class HasId(Protocol): + @property + def id(self) -> Optional[str]: ... + def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... + class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): @@ -14,12 +24,12 @@ def __init__(self, parent_srv, resource_type): self.resource_type = resource_type @property - def baseurl(self): + def baseurl(self) -> str: return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) - def add(self, resource, warning): + def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) @@ -28,7 +38,7 @@ def add(self, resource, warning): return warnings - def update(self, resource, warning): + def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) @@ -37,11 +47,11 @@ def update(self, resource, warning): return warnings - def clear(self, resource): + def clear(self, resource: HasId) -> None: url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) - def populate(self, item): + def populate(self, item: HasId) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -52,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item, req_options=None): + def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..ad80e7d0e 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -12,6 +13,10 @@ from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem, PermissionsRule class Tables(Endpoint, TaggingMixin[TableItem]): @@ -22,11 +27,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + """ + Get information about all tables on the site. Endpoint is paginated, and + will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[TableItem], PaginationItem] + A tuple containing a list of TableItem objects and a PaginationItem + object. + """ logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -36,7 +59,27 @@ def get(self, req_options=None): # Get 1 table @api(version="3.5") - def get_by_id(self, table_id): + def get_by_id(self, table_id: str) -> TableItem: + """ + Get information about a single table on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table + + Parameters + ---------- + table_id : str + The ID of the table to retrieve. + + Returns + ------- + TableItem + A TableItem object representing the table. + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "table ID undefined." raise ValueError(error) @@ -46,7 +89,24 @@ def get_by_id(self, table_id): return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, table_id): + def delete(self, table_id: str) -> None: + """ + Delete a single table from the server. + + Parameters + ---------- + table_id : str + The ID of the table to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "Database ID undefined." raise ValueError(error) @@ -55,7 +115,27 @@ def delete(self, table_id): logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") - def update(self, table_item): + def update(self, table_item: TableItem) -> TableItem: + """ + Update a table on the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table + + Parameters + ---------- + table_item : TableItem + The TableItem object to update. + + Returns + ------- + TableItem + The updated TableItem object. + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "table item missing ID." raise MissingRequiredFieldError(error) @@ -69,21 +149,46 @@ def update(self, table_item): # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item, req_options=None): + def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the columns of a table item. Sets a fetcher function to + retrieve the columns when needed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns + + Parameters + ---------- + table_item : TableItem + The TableItem object to populate columns for. + + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "Table item missing ID. table must be retrieved from server first." raise MissingRequiredFieldError(error) def column_fetcher(): return Pager( - lambda options: self._get_columns_for_table(table_item, options), + lambda options: self._get_columns_for_table(table_item, options), # type: ignore req_options, ) table_item._set_columns(column_fetcher) logger.info(f"Populated columns for table (ID: {table_item.id}") - def _get_columns_for_table(self, table_item, req_options=None): + def _get_columns_for_table( + self, table_item: TableItem, req_options: Optional[RequestOptions] = None + ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,7 +196,25 @@ def _get_columns_for_table(self, table_item, req_options=None): return columns, pagination_item @api(version="3.5") - def update_column(self, table_item, column_item): + def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem: + """ + Update the description of a column in a table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column + + Parameters + ---------- + table_item : TableItem + The TableItem object representing the table. + + column_item : ColumnItem + The ColumnItem object representing the column to update. + + Returns + ------- + ColumnItem + The updated ColumnItem object. + """ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) @@ -101,31 +224,31 @@ def update_column(self, table_item, column_item): return column @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: TableItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None: return self._permissions.delete(item, rules) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: TableItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") From f9bc99bb91346de3d77b2046aa727afa54611029 Mon Sep 17 00:00:00 2001 From: casey-crawford-cfa <91914995+casey-crawford-cfa@users.noreply.github.com> Date: Wed, 14 May 2025 18:54:50 -0500 Subject: [PATCH 564/567] 1580 list extracts on schedule (#1604) * determine what datasources or workbooks are associated with a schedule --- samples/extracts.py | 1 - tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/extract_item.py | 82 +++++++++++++++++++ .../server/endpoint/schedules_endpoint.py | 20 ++++- .../schedule_get_extract_refresh_tasks.xml | 15 ++++ test/request_factory/test_task_requests.py | 1 - test/test_schedule.py | 18 ++++ 7 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tableauserverclient/models/extract_item.py create mode 100644 test/assets/schedule_get_extract_refresh_tasks.xml diff --git a/samples/extracts.py b/samples/extracts.py index 8e7a66aac..d9289452a 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -42,7 +42,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - wb = None ds = None if args.workbook: diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 746bb24dd..30cd88104 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -49,6 +49,7 @@ from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.extract_item import ExtractItem __all__ = [ "ColumnItem", @@ -106,4 +107,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "ExtractItem", ] diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py new file mode 100644 index 000000000..7562ffdde --- /dev/null +++ b/tableauserverclient/models/extract_item.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from defusedxml.ElementTree import fromstring +import xml.etree.ElementTree as ET + + +class ExtractItem: + """ + An extract refresh task item. + + Attributes + ---------- + id : str + The ID of the extract refresh task + priority : int + The priority of the task + type : str + The type of extract refresh (incremental or full) + workbook_id : str, optional + The ID of the workbook if this is a workbook extract + datasource_id : str, optional + The ID of the datasource if this is a datasource extract + """ + + def __init__( + self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + ): + self._id: Optional[str] = None + self._priority = priority + self._type = refresh_type + self._workbook_id = workbook_id + self._datasource_id = datasource_id + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def priority(self) -> int: + return self._priority + + @property + def type(self) -> str: + return self._type + + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @classmethod + def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML response.""" + parsed_response = fromstring(resp) + return cls.from_xml_element(parsed_response, ns) + + @classmethod + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML element.""" + all_extract_items = [] + all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) + + for extract_xml in all_extract_xml: + extract_id = extract_xml.get("id", None) + priority = int(extract_xml.get("priority", 0)) + refresh_type = extract_xml.get("type", "") + + # Check for workbook or datasource + workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns) + datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns) + + workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None + datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None + + extract_item = cls(priority, refresh_type, workbook_id, datasource_id) + extract_item._id = extract_id + + all_extract_items.append(extract_item) + + return all_extract_items diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 8693d66cc..090d400b6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,7 +7,7 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem from tableauserverclient.helpers.logging import logger @@ -261,3 +261,21 @@ def _add_to( ) else: return OK + + @api(version="2.3") + def get_extract_refresh_tasks( + self, schedule_id: str, req_options: Optional["RequestOptions"] = None + ) -> tuple[list["ExtractItem"], "PaginationItem"]: + """Get all extract refresh tasks for the specified schedule.""" + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + + logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})") + url = f"{self.siteurl}/{schedule_id}/extracts" + server_response = self.get_request(url, req_options) + + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) + + return extract_items, pagination_item diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml new file mode 100644 index 000000000..48906dde6 --- /dev/null +++ b/test/assets/schedule_get_extract_refresh_tasks.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py index 0258b8a93..6287fa6ea 100644 --- a/test/request_factory/test_task_requests.py +++ b/test/request_factory/test_task_requests.py @@ -5,7 +5,6 @@ class TestTaskRequest(unittest.TestCase): - def setUp(self): self.task_request = TaskRequest() self.xml_request = ET.Element("tsRequest") diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..4fcc85e18 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -25,6 +25,7 @@ ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") +GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") @@ -405,3 +406,20 @@ def test_add_flow(self) -> None: flow = self.server.flows.get_by_id("bar") result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") + + def test_get_extract_refresh_tasks(self) -> None: + self.server.version = "2.3" + + with open(GET_EXTRACT_TASKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) + + self.assertIsNotNone(extracts) + self.assertIsInstance(extracts[0], list) + self.assertEqual(2, len(extracts[0])) + self.assertEqual("task1", extracts[0][0].id) From cda018b684c98200f3f9de11e379ba2ca3b1b3fe Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 15 May 2025 14:21:50 -0700 Subject: [PATCH 565/567] v0.38 - IDP configuration (#1606) * docs: docstrings for schedules and intervals (#1528) * docs: Docstrings for new fields * feat: enable retrieving only owned workbooks * feat: Add support for multiple IDPs (jorwoods) * feat: Add fields:_all_ support (#1563) * feat: project support all fields * feat: groups all fields * feat: views support all fields * feat: user support _all_ fields * feat: workbook support all fields * feat: datasourceitem _all_ fields * feat: add fields methods to QuerySet * feat: add owner attribute to project * Add SSL option for connecting to Tableau Server with a weaker DH key length Fixes #1582 * feat: 1580 list extracts on schedule (#1604) * chore: type hint database and table objects (#1593) * ci: Switch Slack action to use `ubuntu-latest` like our other actions. --------- Co-authored-by: Jordan Woods Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni Co-authored-by: casey-crawford-cfa <91914995+casey-crawford-cfa@users.noreply.github.com> --- .github/workflows/slack.yml | 2 +- samples/extracts.py | 1 - tableauserverclient/__init__.py | 4 + tableauserverclient/helpers/strings.py | 26 ++- tableauserverclient/models/__init__.py | 7 +- tableauserverclient/models/datasource_item.py | 112 +++++++++++- tableauserverclient/models/extract_item.py | 82 +++++++++ tableauserverclient/models/flow_item.py | 4 +- tableauserverclient/models/group_item.py | 11 ++ tableauserverclient/models/interval_item.py | 40 +++++ tableauserverclient/models/location_item.py | 53 ++++++ tableauserverclient/models/project_item.py | 165 +++++++++++++++--- tableauserverclient/models/schedule_item.py | 57 ++++++ tableauserverclient/models/site_item.py | 28 +++ tableauserverclient/models/table_item.py | 10 +- tableauserverclient/models/tableau_types.py | 14 +- tableauserverclient/models/user_item.py | 102 ++++++++++- tableauserverclient/models/view_item.py | 84 ++++++++- tableauserverclient/models/workbook_item.py | 152 +++++++++++++++- .../server/endpoint/databases_endpoint.py | 119 +++++++++++-- .../server/endpoint/datasources_endpoint.py | 6 +- .../server/endpoint/dqw_endpoint.py | 22 ++- .../server/endpoint/endpoint.py | 39 +++++ .../server/endpoint/schedules_endpoint.py | 134 +++++++++++++- .../server/endpoint/sites_endpoint.py | 19 +- .../server/endpoint/tables_endpoint.py | 157 +++++++++++++++-- .../server/endpoint/users_endpoint.py | 27 ++- tableauserverclient/server/query.py | 36 ++++ tableauserverclient/server/request_factory.py | 5 + tableauserverclient/server/request_options.py | 130 +++++++++++++- tableauserverclient/server/server.py | 42 +++++ test/assets/datasource_get_all_fields.xml | 10 ++ test/assets/group_get_all_fields.xml | 14 ++ test/assets/project_get_all_fields.xml | 9 + .../schedule_get_extract_refresh_tasks.xml | 15 ++ test/assets/site_auth_configurations.xml | 18 ++ test/assets/user_get_all_fields.xml | 11 ++ test/assets/view_get_all_fields.xml | 35 ++++ test/assets/workbook_get_all_fields.xml | 46 +++++ test/request_factory/test_task_requests.py | 1 - test/test_datasource.py | 39 ++++- test/test_group.py | 23 +++ test/test_project.py | 26 +++ test/test_request_option.py | 12 +- test/test_schedule.py | 18 ++ test/test_site.py | 26 +++ test/test_ssl_config.py | 77 ++++++++ test/test_user.py | 89 +++++++++- test/test_view.py | 116 +++++++++++- test/test_workbook.py | 106 ++++++++++- 50 files changed, 2275 insertions(+), 106 deletions(-) create mode 100644 tableauserverclient/models/extract_item.py create mode 100644 tableauserverclient/models/location_item.py create mode 100644 test/assets/datasource_get_all_fields.xml create mode 100644 test/assets/group_get_all_fields.xml create mode 100644 test/assets/project_get_all_fields.xml create mode 100644 test/assets/schedule_get_extract_refresh_tasks.xml create mode 100644 test/assets/site_auth_configurations.xml create mode 100644 test/assets/user_get_all_fields.xml create mode 100644 test/assets/view_get_all_fields.xml create mode 100644 test/assets/workbook_get_all_fields.xml create mode 100644 test/test_ssl_config.py diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 2ecb0be7f..9afebf25b 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -5,7 +5,7 @@ on: [push, pull_request, issues] jobs: slack-notifications: continue-on-error: true - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest name: Sends a message to Slack when a push, a pull request or an issue is made steps: - name: Send message to Slack API diff --git a/samples/extracts.py b/samples/extracts.py index 8e7a66aac..d9289452a 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -42,7 +42,6 @@ def main(): server.add_http_options({"verify": False}) server.use_server_version() with server.auth.sign_in(tableau_auth): - wb = None ds = None if args.workbook: diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 957a820db..21e2c4760 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -25,6 +25,7 @@ LinkedTaskItem, LinkedTaskStepItem, LinkedTaskFlowRunItem, + LocationItem, MetricItem, MonthlyInterval, PaginationItem, @@ -35,6 +36,7 @@ Resource, RevisionItem, ScheduleItem, + SiteAuthConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -101,6 +103,7 @@ "LinkedTaskFlowRunItem", "LinkedTaskItem", "LinkedTaskStepItem", + "LocationItem", "MetricItem", "MissingRequiredFieldError", "MonthlyInterval", @@ -121,6 +124,7 @@ "ServerInfoItem", "ServerResponseError", "SiteItem", + "SiteAuthConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 75534103b..6ba4e48d9 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar +from typing import TypeVar, overload # the redact method can handle either strings or bytes, but it can't mix them. @@ -41,3 +41,27 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e4131b720..30cd88104 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -28,6 +28,7 @@ LinkedTaskStepItem, LinkedTaskFlowRunItem, ) +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -35,7 +36,7 @@ from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.schedule_item import ScheduleItem from tableauserverclient.models.server_info_item import ServerInfoItem -from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.site_item import SiteItem, SiteAuthConfiguration from tableauserverclient.models.subscription_item import SubscriptionItem from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth @@ -48,6 +49,7 @@ from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.extract_item import ExtractItem __all__ = [ "ColumnItem", @@ -75,6 +77,7 @@ "MonthlyInterval", "HourlyInterval", "BackgroundJobItem", + "LocationItem", "MetricItem", "PaginationItem", "Permission", @@ -83,6 +86,7 @@ "RevisionItem", "ScheduleItem", "ServerInfoItem", + "SiteAuthConfiguration", "SiteItem", "SubscriptionItem", "TableItem", @@ -103,4 +107,5 @@ "LinkedTaskItem", "LinkedTaskStepItem", "LinkedTaskFlowRunItem", + "ExtractItem", ] diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2005edf7e..5501ee332 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,9 +6,11 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, @@ -16,6 +18,7 @@ ) from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem class DatasourceItem: @@ -40,6 +43,9 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. + connected_workbooks_count : Optional[int] + The number of workbooks connected to the datasource. + connections : list[ConnectionItem] The list of data connections (ConnectionItem) for the specified data source. You must first call the populate_connections method to access @@ -67,6 +73,12 @@ class DatasourceItem: A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. + favorites_total : Optional[int] + The number of users who have marked the data source as a favorite. + + has_alert : Optional[bool] + A Boolean value that indicates whether the data source has an alert. + has_extracts : Optional[bool] A Boolean value that indicates whether the datasource has extracts. @@ -75,13 +87,22 @@ class DatasourceItem: specific data source or to delete a data source with the get_by_id and delete methods. + is_published : Optional[bool] + A Boolean value that indicates whether the data source is published. + name : Optional[str] The name of the data source. If not specified, the name of the published data source file is used. + owner: Optional[UserItem] + The owner of the data source. + owner_id : Optional[str] The identifier of the owner of the data source. + project : Optional[ProjectItem] + The project that the data source belongs to. + project_id : Optional[str] The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. @@ -89,6 +110,9 @@ class DatasourceItem: project_name : Optional[str] The name of the project associated with the data source. + server_name : Optional[str] + The name of the server where the data source is published. + tags : Optional[set[str]] The tags (list of strings) that have been added to the data source. @@ -143,6 +167,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self.owner_id: Optional[str] = None self.project_id: Optional[str] = project_id self.tags: set[str] = set() + self._connected_workbooks_count: Optional[int] = None + self._favorites_total: Optional[int] = None + self._has_alert: Optional[bool] = None + self._is_published: Optional[bool] = None + self._server_name: Optional[str] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None self._permissions = None self._data_quality_warnings = None @@ -274,14 +305,42 @@ def revisions(self) -> list[RevisionItem]: def size(self) -> Optional[int]: return self._size + @property + def connected_workbooks_count(self) -> Optional[int]: + return self._connected_workbooks_count + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + + @property + def has_alert(self) -> Optional[bool]: + return self._has_alert + + @property + def is_published(self) -> Optional[bool]: + return self._is_published + + @property + def server_name(self) -> Optional[str]: + return self._server_name + + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def _set_connections(self, connections) -> None: self._connections = connections def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _set_revisions(self, revisions): self._revisions = revisions @@ -310,6 +369,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -331,6 +397,13 @@ def _parse_common_elements(self, datasource_xml, ns): use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) return self @@ -355,6 +428,13 @@ def _set_values( use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -394,6 +474,20 @@ def _set_values( self._webpage_url = webpage_url if size is not None: self._size = int(size) + if connected_workbooks_count is not None: + self._connected_workbooks_count = connected_workbooks_count + if favorites_total is not None: + self._favorites_total = favorites_total + if has_alert is not None: + self._has_alert = has_alert + if is_published is not None: + self._is_published = is_published + if server_name is not None: + self._server_name = server_name + if project is not None: + self._project = project + if owner is not None: + self._owner = owner @classmethod def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: @@ -428,6 +522,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) size = datasource_xml.get("size", None) + connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None)) + favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None)) + has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None)) + is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None)) + server_name = datasource_xml.get("serverName", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -438,12 +537,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: + project = ProjectItem.from_xml(project_elem, ns) project_id = project_elem.get("id", None) project_name = project_elem.get("name", None) owner_id = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: + owner = UserItem.from_xml(owner_elem, ns) owner_id = owner_elem.get("id", None) ask_data_enablement = None @@ -471,4 +572,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: use_remote_query_agent, webpage_url, size, + connected_workbooks_count, + favorites_total, + has_alert, + is_published, + server_name, + project, + owner, ) diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py new file mode 100644 index 000000000..7562ffdde --- /dev/null +++ b/tableauserverclient/models/extract_item.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from defusedxml.ElementTree import fromstring +import xml.etree.ElementTree as ET + + +class ExtractItem: + """ + An extract refresh task item. + + Attributes + ---------- + id : str + The ID of the extract refresh task + priority : int + The priority of the task + type : str + The type of extract refresh (incremental or full) + workbook_id : str, optional + The ID of the workbook if this is a workbook extract + datasource_id : str, optional + The ID of the datasource if this is a datasource extract + """ + + def __init__( + self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + ): + self._id: Optional[str] = None + self._priority = priority + self._type = refresh_type + self._workbook_id = workbook_id + self._datasource_id = datasource_id + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def priority(self) -> int: + return self._priority + + @property + def type(self) -> str: + return self._type + + @property + def workbook_id(self) -> Optional[str]: + return self._workbook_id + + @property + def datasource_id(self) -> Optional[str]: + return self._datasource_id + + @classmethod + def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML response.""" + parsed_response = fromstring(resp) + return cls.from_xml_element(parsed_response, ns) + + @classmethod + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + """Create ExtractItem objects from XML element.""" + all_extract_items = [] + all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) + + for extract_xml in all_extract_xml: + extract_id = extract_xml.get("id", None) + priority = int(extract_xml.get("priority", 0)) + refresh_type = extract_xml.get("type", "") + + # Check for workbook or datasource + workbook_elem = extract_xml.find(".//t:workbook", namespaces=ns) + datasource_elem = extract_xml.find(".//t:datasource", namespaces=ns) + + workbook_id = workbook_elem.get("id", None) if workbook_elem is not None else None + datasource_id = datasource_elem.get("id", None) if datasource_elem is not None else None + + extract_item = cls(priority, refresh_type, workbook_id, datasource_id) + extract_item._id = extract_id + + all_extract_items.append(extract_item) + + return all_extract_items diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0083776bb..063897e41 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -146,8 +146,8 @@ def _set_connections(self, connections): def _set_permissions(self, permissions): self._permissions = permissions - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw): + self._data_quality_warnings = dqw def _parse_common_elements(self, flow_xml, ns): if not isinstance(flow_xml, ET.Element): diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 0afd5582c..00f35e518 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -44,6 +44,11 @@ class GroupItem: login to a site. When the mode is onSync, a license is granted for group members each time the domain is synced. + Attributes + ---------- + user_count: Optional[int] + The number of users in the group. + Examples -------- >>> # Create a new group item @@ -65,6 +70,7 @@ def __init__(self, name=None, domain_name=None) -> None: self._users: Optional[Callable[..., "Pager"]] = None self.name: Optional[str] = name self.domain_name: Optional[str] = domain_name + self._user_count: Optional[int] = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @@ -118,6 +124,10 @@ def users(self) -> "Pager": def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users + @property + def user_count(self) -> Optional[int]: + return self._user_count + @classmethod def from_response(cls, resp, ns) -> list["GroupItem"]: all_group_items = list() @@ -127,6 +137,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: name = group_xml.get("name", None) group_item = cls(name) group_item._id = group_xml.get("id", None) + group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None # Domain name is returned in a domain element for some calls domain_elem = group_xml.find(".//t:domain", namespaces=ns) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index d7cf891cc..14cec1878 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -2,6 +2,13 @@ class IntervalItem: + """ + This class sets the frequency and start time of the scheduled item. This + class contains the classes for the hourly, daily, weekly, and monthly + intervals. This class mirrors the options you can set using the REST API and + the Tableau Server interface. + """ + class Frequency: Hourly = "Hourly" Daily = "Daily" @@ -26,6 +33,19 @@ class Day: class HourlyInterval: + """ + Runs scheduled item hourly. To set the hourly interval, you create an + instance of the HourlyInterval class and assign the following values: + start_time, end_time, and interval_value. To set the start_time and + end_time, assign the time value using this syntax: start_time=time(hour, minute) + and end_time=time(hour, minute). The hour is specified in 24 hour time. + The interval_value specifies how often the to run the task within the + start and end time. The options are expressed in hours. For example, + interval_value=.25 is every 15 minutes. The values are .25, .5, 1, 2, 4, 6, + 8, 12. Hourly schedules that run more frequently than every 60 minutes must + have start and end times that are on the hour. + """ + def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time @@ -109,6 +129,12 @@ def _interval_type_pairs(self): class DailyInterval: + """ + Runs the scheduled item daily. To set the daily interval, you create an + instance of the DailyInterval and assign the start_time. The start time uses + the syntax start_time=time(hour, minute). + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -177,6 +203,15 @@ def _interval_type_pairs(self): class WeeklyInterval: + """ + Runs the scheduled item once a week. To set the weekly interval, you create + an instance of the WeeklyInterval and assign the start time and multiple + instances for the interval_value (days of week and start time). The start + time uses the syntax time(hour, minute). The interval_value is the day of + the week, expressed as a IntervalItem. For example + TSC.IntervalItem.Day.Monday for Monday. + """ + def __init__(self, start_time, *interval_values): self.start_time = start_time self.interval = interval_values @@ -214,6 +249,11 @@ def _interval_type_pairs(self): class MonthlyInterval: + """ + Runs the scheduled item once a month. To set the monthly interval, you + create an instance of the MonthlyInterval and assign the start time and day. + """ + def __init__(self, start_time, interval_value): self.start_time = start_time diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py new file mode 100644 index 000000000..fa7c2ff2c --- /dev/null +++ b/tableauserverclient/models/location_item.py @@ -0,0 +1,53 @@ +from typing import Optional +import xml.etree.ElementTree as ET + + +class LocationItem: + """ + Details of where an item is located, such as a personal space or project. + + Attributes + ---------- + id : str | None + The ID of the location. + + type : str | None + The type of location, such as PersonalSpace or Project. + + name : str | None + The name of the location. + """ + + class Type: + PersonalSpace = "PersonalSpace" + Project = "Project" + + def __init__(self): + self._id: Optional[str] = None + self._type: Optional[str] = None + self._name: Optional[str] = None + + def __repr__(self): + return f"{self.__class__.__name__}({self.__dict__!r})" + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def type(self) -> Optional[str]: + return self._type + + @property + def name(self) -> Optional[str]: + return self._name + + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + if ns is None: + ns = {} + location = cls() + location._id = xml.get("id", None) + location._type = xml.get("type", None) + location._name = xml.get("name", None) + return location diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 9be1196ba..1ab369ba7 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,11 +1,11 @@ -import logging import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.property_decorators import property_is_enum +from tableauserverclient.models.user_item import UserItem class ProjectItem: @@ -39,12 +39,32 @@ class corresponds to the project resources you can access using the Tableau Attributes ---------- + datasource_count : int + The number of data sources in the project. + id : str The unique identifier for the project. + owner: Optional[UserItem] + The UserItem owner of the project. + owner_id : str The unique identifier for the UserItem owner of the project. + project_count : int + The number of projects in the project. + + top_level_project : bool + True if the project is a top-level project. + + view_count : int + The number of views in the project. + + workbook_count : int + The number of workbooks in the project. + + writeable : bool + True if the project is writeable. """ ERROR_MSG = "Project item must be populated with permissions first." @@ -75,6 +95,8 @@ def __init__( self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples self._owner_id: Optional[str] = None + self._top_level_project: Optional[bool] = None + self._writeable: Optional[bool] = None self._permissions = None self._default_workbook_permissions = None @@ -87,6 +109,13 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None + self._project_count: Optional[int] = None + self._workbook_count: Optional[int] = None + self._view_count: Optional[int] = None + self._datasource_count: Optional[int] = None + + self._owner: Optional[UserItem] = None + @property def content_permissions(self): return self._content_permissions @@ -176,25 +205,53 @@ def owner_id(self) -> Optional[str]: def owner_id(self, value: str) -> None: self._owner_id = value + @property + def top_level_project(self) -> Optional[bool]: + return self._top_level_project + + @property + def writeable(self) -> Optional[bool]: + return self._writeable + + @property + def project_count(self) -> Optional[int]: + return self._project_count + + @property + def workbook_count(self) -> Optional[int]: + return self._workbook_count + + @property + def view_count(self) -> Optional[int]: + return self._view_count + + @property + def datasource_count(self) -> Optional[int]: + return self._datasource_count + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + def is_default(self): return self.name.lower() == "default" - def _parse_common_tags(self, project_xml, ns): - if not isinstance(project_xml, ET.Element): - project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns) - - if project_xml is not None: - ( - _, - name, - description, - content_permissions, - parent_id, - ) = self._parse_element(project_xml) - self._set_values(None, name, description, content_permissions, parent_id) - return self - - def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id): + def _set_values( + self, + project_id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ): if project_id is not None: self._id = project_id if name: @@ -207,6 +264,20 @@ def _set_values(self, project_id, name, description, content_permissions, parent self.parent_id = parent_id if owner_id: self._owner_id = owner_id + if project_count is not None: + self._project_count = project_count + if workbook_count is not None: + self._workbook_count = workbook_count + if view_count is not None: + self._view_count = view_count + if datasource_count is not None: + self._datasource_count = datasource_count + if top_level_project is not None: + self._top_level_project = top_level_project + if writeable is not None: + self._writeable = writeable + if owner is not None: + self._owner = owner def _set_permissions(self, permissions): self._permissions = permissions @@ -220,31 +291,71 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp, ns) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) for project_xml in all_project_xml: - project_item = cls.from_xml(project_xml) + project_item = cls.from_xml(project_xml, namespace=ns) all_project_items.append(project_item) return all_project_items @classmethod - def from_xml(cls, project_xml, namespace=None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": project_item = cls() - project_item._set_values(*cls._parse_element(project_xml)) + project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml): + def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) content_permissions = project_xml.get("contentPermissions", None) parent_id = project_xml.get("parentProjectId", None) + top_level_project = str_to_bool(project_xml.get("topLevelProject", None)) + writeable = str_to_bool(project_xml.get("writeable", None)) owner_id = None - for owner in project_xml: - owner_id = owner.get("id", None) + owner = None + if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None: + owner = UserItem.from_xml(owner_elem, namespace) + owner_id = owner_elem.get("id", None) + + project_count = None + workbook_count = None + view_count = None + datasource_count = None + if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None: + project_count = int(count_elem.get("projectCount", 0)) + workbook_count = int(count_elem.get("workbookCount", 0)) + view_count = int(count_elem.get("viewCount", 0)) + datasource_count = int(count_elem.get("dataSourceCount", 0)) + + return ( + id, + name, + description, + content_permissions, + parent_id, + owner_id, + top_level_project, + writeable, + project_count, + workbook_count, + view_count, + datasource_count, + owner, + ) + + +@overload +def str_to_bool(value: str) -> bool: ... + + +@overload +def str_to_bool(value: None) -> None: ... + - return id, name, description, content_permissions, parent_id, owner_id +def str_to_bool(value): + return value.lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index e39042058..a2118e3d6 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -20,6 +20,63 @@ class ScheduleItem: + """ + Using the TSC library, you can schedule extract refresh or subscription + tasks on Tableau Server. You can also get and update information about the + scheduled tasks, or delete scheduled tasks. + + If you have the identifier of the job, you can use the TSC library to find + out the status of the asynchronous job. + + The schedule properties are defined in the ScheduleItem class. The class + corresponds to the properties for schedules you can access in Tableau + Server or by using the Tableau Server REST API. The Schedule methods are + based upon the endpoints for jobs in the REST API and operate on the JobItem + class. + + Parameters + ---------- + name : str + The name of the schedule. + + priority : int + The priority of the schedule. Lower values represent higher priority, + with 0 indicating the highest priority. + + schedule_type : str + The type of task schedule. See ScheduleItem.Type for the possible values. + + execution_order : str + Specifies how the scheduled tasks should run. The choices are Parallel + which uses all avaiable background processes for a scheduled task, or + Serial, which limits the schedule to one background process. + + interval_item : Interval + Specifies the frequency that the scheduled task should run. The + interval_item is an instance of the IntervalItem class. The + interval_item has properties for frequency (hourly, daily, weekly, + monthly), and what time and date the scheduled item runs. You set this + value by declaring an IntervalItem object that is one of the following: + HourlyInterval, DailyInterval, WeeklyInterval, or MonthlyInterval. + + Attributes + ---------- + created_at : datetime + The date and time the schedule was created. + + end_schedule_at : datetime + The date and time the schedule ends. + + id : str + The unique identifier for the schedule. + + next_run_at : datetime + The date and time the schedule is next run. + + state : str + The state of the schedule. See ScheduleItem.State for the possible values. + """ + class Type: Extract = "Extract" Flow = "Flow" diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index e4e146f9c..ab65b97b5 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1188,6 +1188,34 @@ def _parse_element(site_xml, ns): ) +class SiteAuthConfiguration: + """ + Authentication configuration for a site. + """ + + def __init__(self): + self.auth_setting: Optional[str] = None + self.enabled: Optional[bool] = None + self.idp_configuration_id: Optional[str] = None + self.idp_configuration_name: Optional[str] = None + self.known_provider_alias: Optional[str] = None + + @classmethod + def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: + all_auth_configs = list() + parsed_response = fromstring(resp) + all_auth_xml = parsed_response.findall(".//t:siteAuthConfiguration", namespaces=ns) + for auth_xml in all_auth_xml: + auth_config = cls() + auth_config.auth_setting = auth_xml.get("authSetting", None) + auth_config.enabled = string_to_bool(auth_xml.get("enabled", "")) + auth_config.idp_configuration_id = auth_xml.get("idpConfigurationId", None) + auth_config.idp_configuration_name = auth_xml.get("idpConfigurationName", None) + auth_config.known_provider_alias = auth_xml.get("knownProviderAlias", None) + all_auth_configs.append(auth_config) + return all_auth_configs + + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 0afdd4df3..541f84360 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,8 +1,12 @@ +from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_boolean +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem + class TableItem: def __init__(self, name, description=None): @@ -40,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self): + def id(self) -> Optional[str]: return self._id @property @@ -100,8 +104,8 @@ def columns(self): def _set_columns(self, columns): self._columns = columns - def _set_data_quality_warnings(self, dqws): - self._data_quality_warnings = dqws + def _set_data_quality_warnings(self, dqw: Callable[[], list["DQWItem"]]) -> None: + self._data_quality_warnings = dqw def _set_values(self, table_values): if "id" in table_values: diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 01ee3d3a9..e69d02a06 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,8 +1,10 @@ from typing import Union +from tableauserverclient.models.database_item import DatabaseItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.table_item import TableItem from tableauserverclient.models.view_item import ViewItem from tableauserverclient.models.workbook_item import WorkbookItem from tableauserverclient.models.metric_item import MetricItem @@ -25,7 +27,17 @@ class Resource: # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] +TableauItem = Union[ + DatasourceItem, + FlowItem, + MetricItem, + ProjectItem, + ViewItem, + WorkbookItem, + VirtualConnectionItem, + DatabaseItem, + TableItem, +] def plural_type(content_type: Union[Resource, str]) -> str: diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 365e44c1d..c995b4e07 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -7,6 +7,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.site_item import SiteAuthConfiguration from .exceptions import UnpopulatedPropertyError from .property_decorators import ( property_is_enum, @@ -37,6 +38,49 @@ class UserItem: auth_setting: str Required attribute for Tableau Cloud. How the user autenticates to the server. + + Attributes + ---------- + domain_name: Optional[str] + The name of the Active Directory domain ("local" if local authentication + is used). + + email: Optional[str] + The email address of the user. + + external_auth_user_id: Optional[str] + The unique identifier for the user in the external authentication system. + + id: Optional[str] + The unique identifier for the user. + + favorites: dict[str, list] + The favorites of the user. Must be populated with a call to + `populate_favorites()`. + + fullname: Optional[str] + The full name of the user. + + groups: Pager + The groups the user belongs to. Must be populated with a call to + `populate_groups()`. + + last_login: Optional[datetime] + The last time the user logged in. + + locale: Optional[str] + The locale of the user. + + language: Optional[str] + Language setting for the user. + + idp_configuration_id: Optional[str] + The ID of the identity provider configuration. + + workbooks: Pager + The workbooks owned by the user. Must be populated with a call to + `populate_workbooks()`. + """ tag_name: str = "user" @@ -94,6 +138,9 @@ def __init__( self.name: Optional[str] = name self.site_role: Optional[str] = site_role self.auth_setting: Optional[str] = auth_setting + self._locale: Optional[str] = None + self._language: Optional[str] = None + self._idp_configuration_id: Optional[str] = None return None @@ -184,6 +231,26 @@ def groups(self) -> "Pager": raise UnpopulatedPropertyError(error) return self._groups() + @property + def locale(self) -> Optional[str]: + return self._locale + + @property + def language(self) -> Optional[str]: + return self._language + + @property + def idp_configuration_id(self) -> Optional[str]: + """ + IDP configuration id for the user. This is only available on Tableau + Cloud, 3.24 or later + """ + return self._idp_configuration_id + + @idp_configuration_id.setter + def idp_configuration_id(self, value: str) -> None: + self._idp_configuration_id = value + def _set_workbooks(self, workbooks) -> None: self._workbooks = workbooks @@ -204,8 +271,11 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem": email, auth_setting, _, + _, + _, + _, ) = self._parse_element(user_xml, ns) - self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None) + self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None) return self def _set_values( @@ -219,6 +289,9 @@ def _set_values( email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ): if id is not None: self._id = id @@ -238,6 +311,12 @@ def _set_values( self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name + if locale: + self._locale = locale + if language: + self._language = language + if idp_configuration_id: + self._idp_configuration_id = idp_configuration_id @classmethod def from_response(cls, resp, ns) -> list["UserItem"]: @@ -249,6 +328,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: element_name = ".//t:owner" return cls._parse_xml(element_name, resp, ns) + @classmethod + def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + item = cls() + item._set_values(*cls._parse_element(xml, ns)) + return item + @classmethod def _parse_xml(cls, element_name, resp, ns): all_user_items = [] @@ -265,6 +350,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) = cls._parse_element(user_xml, ns) user_item = cls(name, site_role) user_item._set_values( @@ -277,6 +365,9 @@ def _parse_xml(cls, element_name, resp, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) all_user_items.append(user_item) return all_user_items @@ -295,6 +386,9 @@ def _parse_element(user_xml, ns): fullname = user_xml.get("fullName", None) email = user_xml.get("email", None) auth_setting = user_xml.get("authSetting", None) + locale = user_xml.get("locale", None) + language = user_xml.get("language", None) + idp_configuration_id = user_xml.get("idpConfigurationId", None) domain_name = None domain_elem = user_xml.find(".//t:domain", namespaces=ns) @@ -311,6 +405,9 @@ def _parse_element(user_xml, ns): email, auth_setting, domain_name, + locale, + language, + idp_configuration_id, ) class CSVImport: @@ -361,6 +458,9 @@ def create_user_from_line(line: str): values[UserItem.CSVImport.ColumnType.EMAIL], values[UserItem.CSVImport.ColumnType.AUTH], None, + None, + None, + None, ) return user diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 88cec7328..dc8eda9c8 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,15 +1,21 @@ import copy from datetime import datetime from requests import Response -from typing import Callable, Optional +from typing import TYPE_CHECKING, Callable, Optional, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.project_item import ProjectItem from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.user_item import UserItem + +if TYPE_CHECKING: + from tableauserverclient.models.workbook_item import WorkbookItem class ViewItem: @@ -34,9 +40,16 @@ class ViewItem: The image of the view. You must first call the `views.populate_image` method to access the image. + location: Optional[LocationItem], default None + The location of the view. The location can be a personal space or a + project. + name: Optional[str], default None The name of the view. + owner: Optional[UserItem], default None + The owner of the view. + owner_id: Optional[str], default None The ID for the owner of the view. @@ -48,6 +61,9 @@ class ViewItem: The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. + project: Optional[ProjectItem], default None + The project that contains the view. + project_id: Optional[str], default None The ID for the project that contains the view. @@ -60,9 +76,11 @@ class ViewItem: updated_at: Optional[datetime], default None The date and time when the view was last updated. + workbook: Optional[WorkbookItem], default None + The workbook that contains the view. + workbook_id: Optional[str], default None The ID for the workbook that contains the view. - """ def __init__(self) -> None: @@ -84,11 +102,18 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None self.tags: set[str] = set() + self._favorites_total: Optional[int] = None + self._view_url_name: Optional[str] = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } + self._owner: Optional[UserItem] = None + self._project: Optional[ProjectItem] = None + self._workbook: Optional["WorkbookItem"] = None + self._location: Optional[LocationItem] = None + def __str__(self): return "".format( self._id, self.name, self.content_url, self.project_id @@ -190,6 +215,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def view_url_name(self) -> Optional[str]: + return self._view_url_name + + @property + def favorites_total(self) -> Optional[int]: + return self._favorites_total + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -198,6 +231,22 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def project(self) -> Optional["ProjectItem"]: + return self._project + + @property + def workbook(self) -> Optional["WorkbookItem"]: + return self._workbook + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: @@ -228,7 +277,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": workbook_elem = view_xml.find(".//t:workbook", namespaces=ns) owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) - tags_elem = view_xml.find(".//t:tags", namespaces=ns) + tags_elem = view_xml.find("./t:tags", namespaces=ns) data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) @@ -236,22 +285,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": view_item._name = view_xml.get("name", None) view_item._content_url = view_xml.get("contentUrl", None) view_item._sheet_type = view_xml.get("sheetType", None) + view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None)) + view_item._view_url_name = view_xml.get("viewUrlName", None) if usage_elem is not None: total_view = usage_elem.get("totalViewCount", None) if total_view: view_item._total_views = int(total_view) if owner_elem is not None: + user = UserItem.from_xml(owner_elem, ns) + view_item._owner = user view_item._owner_id = owner_elem.get("id", None) if project_elem is not None: - view_item._project_id = project_elem.get("id", None) + project_item = ProjectItem.from_xml(project_elem, ns) + view_item._project = project_item + view_item._project_id = project_item.id if workbook_id: view_item._workbook_id = workbook_id elif workbook_elem is not None: - view_item._workbook_id = workbook_elem.get("id", None) + from tableauserverclient.models.workbook_item import WorkbookItem + + workbook_item = WorkbookItem.from_xml(workbook_elem, ns) + view_item._workbook = workbook_item + view_item._workbook_id = workbook_item.id if tags_elem is not None: tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None: + location = LocationItem.from_xml(location_elem, ns) + view_item._location = location if data_acceleration_config_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) view_item.data_acceleration_config = data_acceleration_config @@ -274,3 +336,15 @@ def parse_data_acceleration_config(data_acceleration_elem): def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 32ab413a4..a3ede65d6 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,11 +2,14 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional +from typing import Callable, Optional, overload from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.location_item import LocationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.user_item import UserItem from .connection_item import ConnectionItem from .exceptions import UnpopulatedPropertyError from .permissions_item import PermissionsRule @@ -51,13 +54,31 @@ class as arguments. The workbook item specifies the project. created_at : Optional[datetime.datetime] The date and time the workbook was created. + default_view_id : Optional[str] + The identifier for the default view of the workbook. + description : Optional[str] User-defined description of the workbook. + encrypt_extracts : Optional[bool] + Indicates whether extracts are encrypted. + + has_extracts : Optional[bool] + Indicates whether the workbook has extracts. + id : Optional[str] The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. + last_published_at : Optional[datetime.datetime] + The date and time the workbook was last published. + + location : Optional[LocationItem] + The location of the workbook, such as a personal space or project. + + owner : Optional[UserItem] + The owner of the workbook. + owner_id : Optional[str] The identifier for the owner (UserItem) of the workbook. @@ -65,6 +86,9 @@ class as arguments. The workbook item specifies the project. The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. + project: Optional[ProjectItem] + The project that contains the workbook. + project_name : Optional[str] The name of the project that contains the workbook. @@ -139,6 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id + self._sheet_count: Optional[int] = None + self._has_extracts: Optional[bool] = None + self._project: Optional[ProjectItem] = None + self._owner: Optional[UserItem] = None + self._location: Optional[LocationItem] = None + self._encrypt_extracts: Optional[bool] = None + self._default_view_id: Optional[str] = None + self._share_description: Optional[str] = None + self._last_published_at: Optional[datetime.datetime] = None return None @@ -234,6 +267,14 @@ def show_tabs(self, value: bool): def size(self): return self._size + @property + def sheet_count(self) -> Optional[int]: + return self._sheet_count + + @property + def has_extracts(self) -> Optional[bool]: + return self._has_extracts + @property def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at @@ -300,6 +341,34 @@ def thumbnails_group_id(self) -> Optional[str]: def thumbnails_group_id(self, value: str): self._thumbnails_group_id = value + @property + def project(self) -> Optional[ProjectItem]: + return self._project + + @property + def owner(self) -> Optional[UserItem]: + return self._owner + + @property + def location(self) -> Optional[LocationItem]: + return self._location + + @property + def encrypt_extracts(self) -> Optional[bool]: + return self._encrypt_extracts + + @property + def default_view_id(self) -> Optional[str]: + return self._default_view_id + + @property + def share_description(self) -> Optional[str]: + return self._share_description + + @property + def last_published_at(self) -> Optional[datetime.datetime]: + return self._last_published_at + def _set_connections(self, connections): self._connections = connections @@ -342,6 +411,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -361,6 +439,15 @@ def _parse_common_tags(self, workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) return self @@ -383,6 +470,15 @@ def _set_values( views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ): if id is not None: self._id = id @@ -417,6 +513,24 @@ def _set_values( self.data_acceleration_config = data_acceleration_config if data_freshness_policy is not None: self.data_freshness_policy = data_freshness_policy + if sheet_count is not None: + self._sheet_count = sheet_count + if has_extracts is not None: + self._has_extracts = has_extracts + if project: + self._project = project + if owner: + self._owner = owner + if location: + self._location = location + if encrypt_extracts is not None: + self._encrypt_extracts = encrypt_extracts + if default_view_id is not None: + self._default_view_id = default_view_id + if share_description is not None: + self._share_description = share_description + if last_published_at is not None: + self._last_published_at = last_published_at @classmethod def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]: @@ -443,6 +557,12 @@ def _parse_element(workbook_xml, ns): created_at = parse_datetime(workbook_xml.get("createdAt", None)) description = workbook_xml.get("description", None) updated_at = parse_datetime(workbook_xml.get("updatedAt", None)) + sheet_count = string_to_int(workbook_xml.get("sheetCount", None)) + has_extracts = string_to_bool(workbook_xml.get("hasExtracts", "")) + encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None + default_view_id = workbook_xml.get("defaultViewId", None) + share_description = workbook_xml.get("shareDescription", None) + last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None)) size = workbook_xml.get("size", None) if size: @@ -452,14 +572,18 @@ def _parse_element(workbook_xml, ns): project_id = None project_name = None + project = None project_tag = workbook_xml.find(".//t:project", namespaces=ns) if project_tag is not None: + project = ProjectItem.from_xml(project_tag, ns) project_id = project_tag.get("id", None) project_name = project_tag.get("name", None) owner_id = None + owner = None owner_tag = workbook_xml.find(".//t:owner", namespaces=ns) if owner_tag is not None: + owner = UserItem.from_xml(owner_tag, ns) owner_id = owner_tag.get("id", None) tags = None @@ -473,6 +597,11 @@ def _parse_element(workbook_xml, ns): if views_elem is not None: views = ViewItem.from_xml_element(views_elem, ns) + location = None + location_elem = workbook_xml.find(".//t:location", namespaces=ns) + if location_elem is not None: + location = LocationItem.from_xml(location_elem, ns) + data_acceleration_config = { "acceleration_enabled": None, "accelerate_now": None, @@ -505,6 +634,15 @@ def _parse_element(workbook_xml, ns): views, data_acceleration_config, data_freshness_policy, + sheet_count, + has_extracts, + project, + owner, + location, + encrypt_extracts, + default_view_id, + share_description, + last_published_at, ) @@ -535,3 +673,15 @@ def parse_data_acceleration_config(data_acceleration_elem): # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: return s.lower() == "true" + + +@overload +def string_to_int(s: None) -> None: ... + + +@overload +def string_to_int(s: str) -> int: ... + + +def string_to_int(s): + return int(s) if s is not None else None diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index c0e106eb2..dc88ceaa5 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import TYPE_CHECKING, Optional, Union from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint @@ -13,6 +14,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.dqw_item import DQWItem + from tableauserverclient.server.request_options import RequestOptions + class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): @@ -23,11 +28,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(parent_srv, Resource.Database) @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + """ + Get information about all databases on the site. Endpoint is paginated, + and will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_databases + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[DatabaseItem], PaginationItem] + A tuple containing a list of DatabaseItem objects and a + PaginationItem object. + """ logger.info("Querying all databases on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -37,7 +60,27 @@ def get(self, req_options=None): # Get 1 database @api(version="3.5") - def get_by_id(self, database_id): + def get_by_id(self, database_id: str) -> DatabaseItem: + """ + Get information about a single database asset on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_database + + Parameters + ---------- + database_id : str + The ID of the database to retrieve. + + Returns + ------- + DatabaseItem + A DatabaseItem object representing the database. + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "database ID undefined." raise ValueError(error) @@ -47,7 +90,24 @@ def get_by_id(self, database_id): return DatabaseItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, database_id): + def delete(self, database_id: str) -> None: + """ + Deletes a single database asset from the server. + + Parameters + ---------- + database_id : str + The ID of the database to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the database ID is undefined. + """ if not database_id: error = "Database ID undefined." raise ValueError(error) @@ -56,7 +116,28 @@ def delete(self, database_id): logger.info(f"Deleted single database (ID: {database_id})") @api(version="3.5") - def update(self, database_item): + def update(self, database_item: DatabaseItem) -> DatabaseItem: + """ + Update the database description, certify the database, set permissions, + or assign a User as the database contact. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_database + + Parameters + ---------- + database_item : DatabaseItem + The DatabaseItem object to update. + + Returns + ------- + DatabaseItem + The updated DatabaseItem object. + + Raises + ------ + MissingRequiredFieldError + If the database item is missing an ID. + """ if not database_item.id: error = "Database item missing ID." raise MissingRequiredFieldError(error) @@ -88,43 +169,45 @@ def _get_tables_for_database(self, database_item): return tables @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: DatabaseItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: DatabaseItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: DatabaseItem, rules: list[PermissionsRule]) -> None: self._permissions.delete(item, rules) @api(version="3.5") - def populate_table_default_permissions(self, item): + def populate_table_default_permissions(self, item: DatabaseItem): self._default_permissions.populate_default_permissions(item, Resource.Table) @api(version="3.5") - def update_table_default_permissions(self, item): - return self._default_permissions.update_default_permissions(item, Resource.Table) + def update_table_default_permissions( + self, item: DatabaseItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + return self._default_permissions.update_default_permissions(item, rules, Resource.Table) @api(version="3.5") - def delete_table_default_permissions(self, item): - self._default_permissions.delete_default_permission(item, Resource.Table) + def delete_table_default_permissions(self, rule: PermissionsRule, item: DatabaseItem) -> None: + self._default_permissions.delete_default_permission(item, rule, Resource.Table) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatabaseItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 69913a724..168446974 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -733,7 +733,7 @@ def populate_dqw(self, item) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Update the warning type, status, and message of a data quality warning. @@ -755,7 +755,7 @@ def update_dqw(self, item, warning): return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: DatasourceItem, warning: "DQWItem") -> list["DQWItem"]: """ Add a data quality warning to a datasource. @@ -786,7 +786,7 @@ def add_dqw(self, item, warning): return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: DatasourceItem) -> None: """ Delete a data quality warnings from an asset. diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index 90e31483b..d2ad517ee 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,4 +1,5 @@ import logging +from typing import Callable, Optional, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -7,6 +8,15 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class HasId(Protocol): + @property + def id(self) -> Optional[str]: ... + def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... + class _DataQualityWarningEndpoint(Endpoint): def __init__(self, parent_srv, resource_type): @@ -14,12 +24,12 @@ def __init__(self, parent_srv, resource_type): self.resource_type = resource_type @property - def baseurl(self): + def baseurl(self) -> str: return "{}/sites/{}/dataQualityWarnings/{}".format( self.parent_srv.baseurl, self.parent_srv.site_id, self.resource_type ) - def add(self, resource, warning): + def add(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.add_req(warning) response = self.post_request(url, add_req) @@ -28,7 +38,7 @@ def add(self, resource, warning): return warnings - def update(self, resource, warning): + def update(self, resource: HasId, warning: DQWItem) -> list[DQWItem]: url = f"{self.baseurl}/{resource.id}" add_req = RequestFactory.DQW.update_req(warning) response = self.put_request(url, add_req) @@ -37,11 +47,11 @@ def update(self, resource, warning): return warnings - def clear(self, resource): + def clear(self, resource: HasId) -> None: url = f"{self.baseurl}/{resource.id}" return self.delete_request(url) - def populate(self, item): + def populate(self, item: HasId) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -52,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item, req_options=None): + def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 9e1160705..21462af5f 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -14,6 +14,7 @@ TypeVar, Union, ) +from typing_extensions import Self from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]: @abc.abstractmethod def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") + + def fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) | set(("_default_",)) + return queryset + + def only_fields(self: Self, *fields: str) -> QuerySet: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + queryset = QuerySet(self) + queryset.request_options.fields |= set(fields) + return queryset diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index eec4536f9..090d400b6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -7,7 +7,7 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem +from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem from tableauserverclient.helpers.logging import logger @@ -30,6 +30,23 @@ def siteurl(self) -> str: @api(version="2.3") def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + """ + Returns a list of flows, extract, and subscription server schedules on + Tableau Server. For each schedule, the API returns name, frequency, + priority, and other information. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_schedules + + Parameters + ---------- + req_options : Optional[RequestOptions] + Filtering and paginating options for request. + + Returns + ------- + Tuple[List[ScheduleItem], PaginationItem] + A tuple of list of ScheduleItem and PaginationItem + """ logger.info("Querying all schedules") url = self.baseurl server_response = self.get_request(url, req_options) @@ -38,7 +55,22 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche return all_schedule_items, pagination_item @api(version="3.8") - def get_by_id(self, schedule_id): + def get_by_id(self, schedule_id: str) -> ScheduleItem: + """ + Returns detailed information about the specified server schedule. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#get-schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to get information for. + + Returns + ------- + ScheduleItem + The schedule item that corresponds to the given ID. + """ if not schedule_id: error = "No Schedule ID provided" raise ValueError(error) @@ -49,6 +81,20 @@ def get_by_id(self, schedule_id): @api(version="2.3") def delete(self, schedule_id: str) -> None: + """ + Deletes the specified schedule from the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#delete_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to delete. + + Returns + ------- + None + """ if not schedule_id: error = "Schedule ID undefined" raise ValueError(error) @@ -58,6 +104,23 @@ def delete(self, schedule_id: str) -> None: @api(version="2.3") def update(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Modifies settings for the specified server schedule, including the name, + priority, and frequency details on Tableau Server. For Tableau Cloud, + see the tasks and subscritpions API. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#update_schedule + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to update. + + Returns + ------- + ScheduleItem + The updated schedule item. + """ if not schedule_item.id: error = "Schedule item missing ID." raise MissingRequiredFieldError(error) @@ -71,6 +134,20 @@ def update(self, schedule_item: ScheduleItem) -> ScheduleItem: @api(version="2.3") def create(self, schedule_item: ScheduleItem) -> ScheduleItem: + """ + Creates a new server schedule on Tableau Server. For Tableau Cloud, use + the tasks and subscriptions API. + + Parameters + ---------- + schedule_item : ScheduleItem + The schedule item to create. + + Returns + ------- + ScheduleItem + The newly created schedule. + """ if schedule_item.interval_item is None: error = "Interval item must be defined." raise MissingRequiredFieldError(error) @@ -92,6 +169,41 @@ def add_to_schedule( flow: Optional["FlowItem"] = None, task_type: Optional[str] = None, ) -> list[AddResponse]: + """ + Adds a workbook, datasource, or flow to a schedule on Tableau Server. + Only one of workbook, datasource, or flow can be passed in at a time. + + The task type is optional and will default to ExtractRefresh if a + workbook or datasource is passed in, and RunFlow if a flow is passed in. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_workbook_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#add_data_source_to_schedule + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#add_flow_task_to_schedule + + Parameters + ---------- + schedule_id : str + The ID of the schedule to add the item to. + + workbook : Optional[WorkbookItem] + The workbook to add to the schedule. + + datasource : Optional[DatasourceItem] + The datasource to add to the schedule. + + flow : Optional[FlowItem] + The flow to add to the schedule. + + task_type : Optional[str] + The type of task to add to the schedule. If not provided, it will + default to ExtractRefresh if a workbook or datasource is passed in, + and RunFlow if a flow is passed in. + + Returns + ------- + list[AddResponse] + A list of responses for each item added to the schedule. + """ # There doesn't seem to be a good reason to allow one item of each type? if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) @@ -149,3 +261,21 @@ def _add_to( ) else: return OK + + @api(version="2.3") + def get_extract_refresh_tasks( + self, schedule_id: str, req_options: Optional["RequestOptions"] = None + ) -> tuple[list["ExtractItem"], "PaginationItem"]: + """Get all extract refresh tasks for the specified schedule.""" + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + + logger.info(f"Querying extract refresh tasks for schedule (ID: {schedule_id})") + url = f"{self.siteurl}/{schedule_id}/extracts" + server_response = self.get_request(url, req_options) + + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) + + return extract_items, pagination_item diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index 55d2a5ad0..e2316fbb8 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -4,7 +4,7 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory -from tableauserverclient.models import SiteItem, PaginationItem +from tableauserverclient.models import SiteAuthConfiguration, SiteItem, PaginationItem from tableauserverclient.helpers.logging import logger @@ -418,3 +418,20 @@ def re_encrypt_extracts(self, site_id: str) -> None: empty_req = RequestFactory.Empty.empty_req() self.post_request(url, empty_req) + + @api(version="3.24") + def list_auth_configurations(self) -> list[SiteAuthConfiguration]: + """ + Lists all authentication configurations on the current site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#list_authentication_configurations_site + + Returns + ------- + list[SiteAuthConfiguration] + A list of authentication configurations on the current site. + """ + url = f"{self.baseurl}/{self.parent_srv.site_id}/site-auth-configurations" + server_response = self.get_request(url) + auth_configurations = SiteAuthConfiguration.from_response(server_response.content, self.parent_srv.namespace) + return auth_configurations diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index 120d3ba9c..ad80e7d0e 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,7 +1,8 @@ import logging -from typing import Union +from typing import Optional, Union, TYPE_CHECKING from collections.abc import Iterable +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import api, Endpoint from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -12,6 +13,10 @@ from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.models import DQWItem, PermissionsRule class Tables(Endpoint, TaggingMixin[TableItem]): @@ -22,11 +27,29 @@ def __init__(self, parent_srv): self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "table") @property - def baseurl(self): + def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options=None): + def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + """ + Get information about all tables on the site. Endpoint is paginated, and + will return a default of 100 items per page. Use the `req_options` + parameter to customize the request. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_tables + + Parameters + ---------- + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + tuple[list[TableItem], PaginationItem] + A tuple containing a list of TableItem objects and a PaginationItem + object. + """ logger.info("Querying all tables on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -36,7 +59,27 @@ def get(self, req_options=None): # Get 1 table @api(version="3.5") - def get_by_id(self, table_id): + def get_by_id(self, table_id: str) -> TableItem: + """ + Get information about a single table on the site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_table + + Parameters + ---------- + table_id : str + The ID of the table to retrieve. + + Returns + ------- + TableItem + A TableItem object representing the table. + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "table ID undefined." raise ValueError(error) @@ -46,7 +89,24 @@ def get_by_id(self, table_id): return TableItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.5") - def delete(self, table_id): + def delete(self, table_id: str) -> None: + """ + Delete a single table from the server. + + Parameters + ---------- + table_id : str + The ID of the table to delete. + + Returns + ------- + None + + Raises + ------ + ValueError + If the table ID is not provided. + """ if not table_id: error = "Database ID undefined." raise ValueError(error) @@ -55,7 +115,27 @@ def delete(self, table_id): logger.info(f"Deleted single table (ID: {table_id})") @api(version="3.5") - def update(self, table_item): + def update(self, table_item: TableItem) -> TableItem: + """ + Update a table on the server. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_table + + Parameters + ---------- + table_item : TableItem + The TableItem object to update. + + Returns + ------- + TableItem + The updated TableItem object. + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "table item missing ID." raise MissingRequiredFieldError(error) @@ -69,21 +149,46 @@ def update(self, table_item): # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item, req_options=None): + def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + """ + Populate the columns of a table item. Sets a fetcher function to + retrieve the columns when needed. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#query_columns + + Parameters + ---------- + table_item : TableItem + The TableItem object to populate columns for. + + req_options : RequestOptions, optional + Options to customize the request. If not provided, defaults to None. + + Returns + ------- + None + + Raises + ------ + MissingRequiredFieldError + If the table item is missing an ID. + """ if not table_item.id: error = "Table item missing ID. table must be retrieved from server first." raise MissingRequiredFieldError(error) def column_fetcher(): return Pager( - lambda options: self._get_columns_for_table(table_item, options), + lambda options: self._get_columns_for_table(table_item, options), # type: ignore req_options, ) table_item._set_columns(column_fetcher) logger.info(f"Populated columns for table (ID: {table_item.id}") - def _get_columns_for_table(self, table_item, req_options=None): + def _get_columns_for_table( + self, table_item: TableItem, req_options: Optional[RequestOptions] = None + ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) columns = ColumnItem.from_response(server_response.content, self.parent_srv.namespace) @@ -91,7 +196,25 @@ def _get_columns_for_table(self, table_item, req_options=None): return columns, pagination_item @api(version="3.5") - def update_column(self, table_item, column_item): + def update_column(self, table_item: TableItem, column_item: ColumnItem) -> ColumnItem: + """ + Update the description of a column in a table. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_metadata.htm#update_column + + Parameters + ---------- + table_item : TableItem + The TableItem object representing the table. + + column_item : ColumnItem + The ColumnItem object representing the column to update. + + Returns + ------- + ColumnItem + The updated ColumnItem object. + """ url = f"{self.baseurl}/{table_item.id}/columns/{column_item.id}" update_req = RequestFactory.Column.update_req(column_item) server_response = self.put_request(url, update_req) @@ -101,31 +224,31 @@ def update_column(self, table_item, column_item): return column @api(version="3.5") - def populate_permissions(self, item): + def populate_permissions(self, item: TableItem) -> None: self._permissions.populate(item) @api(version="3.5") - def update_permissions(self, item, rules): + def update_permissions(self, item: TableItem, rules: list[PermissionsRule]) -> list[PermissionsRule]: return self._permissions.update(item, rules) @api(version="3.5") - def delete_permission(self, item, rules): + def delete_permission(self, item: TableItem, rules: list[PermissionsRule]) -> None: return self._permissions.delete(item, rules) @api(version="3.5") - def populate_dqw(self, item): + def populate_dqw(self, item: TableItem) -> None: self._data_quality_warnings.populate(item) @api(version="3.5") - def update_dqw(self, item, warning): + def update_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.update(item, warning) @api(version="3.5") - def add_dqw(self, item, warning): + def add_dqw(self, item: TableItem, warning: "DQWItem") -> list["DQWItem"]: return self._data_quality_warnings.add(item, warning) @api(version="3.5") - def delete_dqw(self, item): + def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index d81907ae9..17af21a03 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt if req_options is None: req_options = RequestOptions() - req_options._all_fields = True + req_options.all_fields = True url = self.baseurl server_response = self.get_request(url, req_options) @@ -381,10 +381,15 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") - def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_workbooks( + self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + ) -> None: """ Returns information about the workbooks that the specified user owns - and has Read (view) permissions for. + or has Read (view) permissions for. If owned_only is set to True, + only the workbooks that the user owns are returned. If owned_only is + set to False, all workbooks that the user has Read (view) permissions + for are returned. This method retrieves the workbook information for the specified user. The REST API is designed to return only the information you ask for @@ -402,6 +407,10 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO req_options : Optional[RequestOptions] Optional request options to filter and sort the results. + owned_only : bool, default=False + If True, only the workbooks that the user owns are returned. + If False, all workbooks that the user has Read (view) permissions + Returns ------- None @@ -423,14 +432,22 @@ def populate_workbooks(self, user_item: UserItem, req_options: Optional[RequestO raise MissingRequiredFieldError(error) def wb_pager(): - return Pager(lambda options: self._get_wbs_for_user(user_item, options), req_options) + def func(req_options): + return self._get_wbs_for_user(user_item, req_options, owned_only=owned_only) + + return Pager(func, req_options) user_item._set_workbooks(wb_pager) def _get_wbs_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, + user_item: UserItem, + req_options: Optional[RequestOptions] = None, + owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" + if owned_only: + url += "?ownedBy=true" server_response = self.get_request(url, req_options) logger.info(f"Populated workbooks for user (ID: {user_item.id})") workbook_item = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 801ad4a13..5137cee52 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be used in addition to the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) | set(("_default_")) + return self + + def only_fields(self: Self, *fields: str) -> Self: + """ + Add fields to the request options. If no fields are provided, the + default fields will be used. If fields are provided, the default fields + will be replaced by the provided fields. + + Parameters + ---------- + fields : str + The fields to include in the request options. + + Returns + ------- + QuerySet + """ + self.request_options.fields |= set(fields) + return self + @staticmethod def _parse_shorthand_filter(key: str) -> tuple[str, str]: tokens = key.split("__", 1) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 575423612..c898004f7 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -913,6 +913,8 @@ def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: user_element.attrib["authSetting"] = user_item.auth_setting if password: user_element.attrib["password"] = password + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) def add_req(self, user_item: UserItem) -> bytes: @@ -929,6 +931,9 @@ def add_req(self, user_item: UserItem) -> bytes: if user_item.auth_setting: user_element.attrib["authSetting"] = user_item.auth_setting + + if user_item.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 504f7f3ca..4a104255f 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,6 @@ import sys from typing import Optional +import warnings from typing_extensions import Self @@ -62,8 +63,21 @@ def __init__(self, pagenumber=1, pagesize=None): self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() + self.fields = set() # This is private until we expand all of our parsers to handle the extra fields - self._all_fields = False + self.all_fields = False + + @property + def _all_fields(self) -> bool: + return self.all_fields + + @_all_fields.setter + def _all_fields(self, value): + warnings.warn( + "Directly setting _all_fields is deprecated, please use the all_fields property instead.", + DeprecationWarning, + ) + self.all_fields = value def get_query_params(self) -> dict: params = {} @@ -75,12 +89,14 @@ def get_query_params(self) -> dict: filter_options = (str(filter_item) for filter_item in self.filter) ordered_filter_options = sorted(filter_options) params["filter"] = ",".join(ordered_filter_options) - if self._all_fields: + if self.all_fields: params["fields"] = "_all_" if self.pagenumber: params["pageNumber"] = self.pagenumber if self.pagesize: params["pageSize"] = self.pagesize + if self.fields: + params["fields"] = ",".join(self.fields) return params def page_size(self, page_size): @@ -181,6 +197,116 @@ class Direction: Desc = "desc" Asc = "asc" + class SelectFields: + class Common: + All = "_all_" + Default = "_default_" + + class ContentsCounts: + ProjectCount = "contentsCounts.projectCount" + ViewCount = "contentsCounts.viewCount" + DatasourceCount = "contentsCounts.datasourceCount" + WorkbookCount = "contentsCounts.workbookCount" + + class Datasource: + ContentUrl = "datasource.contentUrl" + ID = "datasource.id" + Name = "datasource.name" + Type = "datasource.type" + Description = "datasource.description" + CreatedAt = "datasource.createdAt" + UpdatedAt = "datasource.updatedAt" + EncryptExtracts = "datasource.encryptExtracts" + IsCertified = "datasource.isCertified" + UseRemoteQueryAgent = "datasource.useRemoteQueryAgent" + WebPageURL = "datasource.webpageUrl" + Size = "datasource.size" + Tag = "datasource.tag" + FavoritesTotal = "datasource.favoritesTotal" + DatabaseName = "datasource.databaseName" + ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount" + HasAlert = "datasource.hasAlert" + HasExtracts = "datasource.hasExtracts" + IsPublished = "datasource.isPublished" + ServerName = "datasource.serverName" + + class Favorite: + Label = "favorite.label" + ParentProjectName = "favorite.parentProjectName" + TargetOwnerName = "favorite.targetOwnerName" + + class Group: + ID = "group.id" + Name = "group.name" + DomainName = "group.domainName" + UserCount = "group.userCount" + MinimumSiteRole = "group.minimumSiteRole" + + class Job: + ID = "job.id" + Status = "job.status" + CreatedAt = "job.createdAt" + StartedAt = "job.startedAt" + EndedAt = "job.endedAt" + Priority = "job.priority" + JobType = "job.jobType" + Title = "job.title" + Subtitle = "job.subtitle" + + class Owner: + ID = "owner.id" + Name = "owner.name" + FullName = "owner.fullName" + SiteRole = "owner.siteRole" + LastLogin = "owner.lastLogin" + Email = "owner.email" + + class Project: + ID = "project.id" + Name = "project.name" + Description = "project.description" + CreatedAt = "project.createdAt" + UpdatedAt = "project.updatedAt" + ContentPermissions = "project.contentPermissions" + ParentProjectID = "project.parentProjectId" + TopLevelProject = "project.topLevelProject" + Writeable = "project.writeable" + + class User: + ExternalAuthUserId = "user.externalAuthUserId" + ID = "user.id" + Name = "user.name" + SiteRole = "user.siteRole" + LastLogin = "user.lastLogin" + FullName = "user.fullName" + Email = "user.email" + AuthSetting = "user.authSetting" + + class View: + ID = "view.id" + Name = "view.name" + ContentUrl = "view.contentUrl" + CreatedAt = "view.createdAt" + UpdatedAt = "view.updatedAt" + Tags = "view.tags" + SheetType = "view.sheetType" + Usage = "view.usage" + + class Workbook: + ID = "workbook.id" + Description = "workbook.description" + Name = "workbook.name" + ContentUrl = "workbook.contentUrl" + ShowTabs = "workbook.showTabs" + Size = "workbook.size" + CreatedAt = "workbook.createdAt" + UpdatedAt = "workbook.updatedAt" + SheetCount = "workbook.sheetCount" + HasExtracts = "workbook.hasExtracts" + Tags = "workbook.tags" + WebpageUrl = "workbook.webpageUrl" + DefaultViewId = "workbook.defaultViewId" + """ These options can be used by methods that are fetching data exported from a specific content item diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 30c635e31..d5d163db3 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -2,6 +2,7 @@ import requests import urllib3 +import ssl from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -91,6 +92,13 @@ class Server: and a later version of the REST API. For more information, see REST API Versions. + http_options : dict, optional + Additional options to pass to the requests library when making HTTP requests. + + session_factory : callable, optional + A factory function that returns a requests.Session object. If not provided, + requests.session is used. + Examples -------- >>> import tableauserverclient as TSC @@ -107,6 +115,16 @@ class Server: >>> # for example, 2.8 >>> # server.version = '2.8' + >>> # if connecting to an older Tableau Server with weak DH keys (Python 3.12+ only) + >>> server.configure_ssl(allow_weak_dh=True) # Note: reduces security + + Notes + ----- + When using Python 3.12 or later with older versions of Tableau Server, you may encounter + SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions + enforce stronger security requirements. You can temporarily work around this using + configure_ssl(allow_weak_dh=True), but this reduces security and should only be used + as a temporary measure until the server can be upgraded. """ class PublishMode: @@ -125,6 +143,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._auth_token = None self._site_id = None self._user_id = None + self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code if not server_address.startswith("https://") and not server_address.startswith("https://"): @@ -313,3 +332,26 @@ def session(self): def is_signed_in(self): return self._auth_token is not None + + def configure_ssl(self, *, allow_weak_dh=False): + """Configure SSL/TLS settings for the server connection. + + Parameters + ---------- + allow_weak_dh : bool, optional + If True, allows connections to servers with DH keys that are considered too small by modern Python versions. + WARNING: This reduces security and should only be used as a temporary workaround. + """ + if allow_weak_dh: + logger.warning( + "WARNING: Allowing weak Diffie-Hellman keys. This reduces security and should only be used temporarily." + ) + self._ssl_context = ssl.create_default_context() + # Allow weak DH keys by setting minimum key size to 512 bits (default is 1024 in Python 3.12+) + self._ssl_context.set_dh_parameters(min_key_bits=512) + self.add_http_options({"verify": self._ssl_context}) + else: + self._ssl_context = None + # Remove any custom SSL context if we're reverting to default settings + if "verify" in self._http_options: + del self._http_options["verify"] diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml new file mode 100644 index 000000000..46c4396d3 --- /dev/null +++ b/test/assets/datasource_get_all_fields.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml new file mode 100644 index 000000000..0118250e1 --- /dev/null +++ b/test/assets/group_get_all_fields.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml new file mode 100644 index 000000000..d71ebd922 --- /dev/null +++ b/test/assets/project_get_all_fields.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/schedule_get_extract_refresh_tasks.xml b/test/assets/schedule_get_extract_refresh_tasks.xml new file mode 100644 index 000000000..48906dde6 --- /dev/null +++ b/test/assets/schedule_get_extract_refresh_tasks.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/site_auth_configurations.xml b/test/assets/site_auth_configurations.xml new file mode 100644 index 000000000..c81d179ac --- /dev/null +++ b/test/assets/site_auth_configurations.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml new file mode 100644 index 000000000..7e9a62568 --- /dev/null +++ b/test/assets/user_get_all_fields.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml new file mode 100644 index 000000000..236ebd726 --- /dev/null +++ b/test/assets/view_get_all_fields.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml new file mode 100644 index 000000000..007b79338 --- /dev/null +++ b/test/assets/workbook_get_all_fields.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py index 0258b8a93..6287fa6ea 100644 --- a/test/request_factory/test_task_requests.py +++ b/test/request_factory/test_task_requests.py @@ -5,7 +5,6 @@ class TestTaskRequest(unittest.TestCase): - def setUp(self): self.task_request = TaskRequest() self.xml_request = ET.Element("tsRequest") diff --git a/test/test_datasource.py b/test/test_datasource.py index b7e7e2721..a604ba8b0 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -10,7 +10,7 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionItem -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory @@ -20,6 +20,7 @@ GET_XML = "datasource_get.xml" GET_EMPTY_XML = "datasource_get_empty.xml" GET_BY_ID_XML = "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" PUBLISH_XML = "datasource_publish.xml" @@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_get_datasource_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) + datasources, _ = self.server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" diff --git a/test/test_group.py b/test/test_group.py index 41b5992be..b3de07963 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -10,6 +10,7 @@ # TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") @@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None: self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") self.assertEqual(job.mode, "Asynchronous") self.assertEqual(job.type, "GroupSync") + + def test_get_all_fields(self) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = self.server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_project.py b/test/test_project.py index 56787efac..c51f2e1e6 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = asset("project_get.xml") +GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") UPDATE_XML = asset("project_update.xml") SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") CREATE_XML = asset("project_create.xml") @@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self): m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) self.server.projects.delete_virtualconnection_default_permissions(project, rule) + + def test_get_all_fields(self) -> None: + self.server.version = "3.23" + base_url = self.server.projects.baseurl + with open(GET_XML_ALL_FIELDS, "rb") as f: + response_xml = f.read().decode("utf-8") + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = self.server.projects.get(req_options=ro) + + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_request_option.py b/test/test_request_option.py index 7405189a3..57dfdc2a0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -251,7 +251,7 @@ def test_all_fields(self) -> None: m.get(requests_mock.ANY) url = self.baseurl + "/views/456/data" opts = TSC.RequestOptions() - opts._all_fields = True + opts.all_fields = True resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("fields=_all_", resp.request.query)) @@ -368,3 +368,13 @@ def test_language_export(self) -> None: resp = self.server.users.get_request(url, request_object=opts) self.assertTrue(re.search("language=en-us", resp.request.query)) + + def test_queryset_fields(self) -> None: + loop = self.server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + def test_queryset_only_fields(self) -> None: + loop = self.server.users.only_fields("id") + assert "id" in loop.request_options.fields + assert "_default_" not in loop.request_options.fields diff --git a/test/test_schedule.py b/test/test_schedule.py index b072522a4..4fcc85e18 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -25,6 +25,7 @@ ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") +GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") @@ -405,3 +406,20 @@ def test_add_flow(self) -> None: flow = self.server.flows.get_by_id("bar") result = self.server.schedules.add_to_schedule("foo", flow=flow) self.assertEqual(0, len(result), "Added properly") + + def test_get_extract_refresh_tasks(self) -> None: + self.server.version = "2.3" + + with open(GET_EXTRACT_TASKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) + + self.assertIsNotNone(extracts) + self.assertIsInstance(extracts[0], list) + self.assertEqual(2, len(extracts[0])) + self.assertEqual("task1", extracts[0][0].id) diff --git a/test/test_site.py b/test/test_site.py index 96b75f9ff..243810254 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -13,6 +13,7 @@ GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") +SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") class SiteTests(unittest.TestCase): @@ -260,3 +261,28 @@ def test_decrypt(self) -> None: with requests_mock.mock() as m: m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + def test_list_auth_configurations(self) -> None: + self.server.version = "3.24" + self.baseurl = self.server.sites.baseurl + with open(SITE_AUTH_CONFIG_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + assert self.baseurl == self.server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = self.server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" + assert configs[1].idp_configuration_name == "Initial SAML" + assert configs[1].known_provider_alias is None diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py new file mode 100644 index 000000000..036a326ca --- /dev/null +++ b/test/test_ssl_config.py @@ -0,0 +1,77 @@ +import unittest +import ssl +from unittest.mock import patch, MagicMock +from tableauserverclient import Server +from tableauserverclient.server.endpoint import Endpoint +import logging + + +class TestSSLConfig(unittest.TestCase): + @patch("requests.session") + @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") + def setUp(self, mock_set_parameters, mock_session): + """Set up test fixtures with mocked session and request validation""" + # Mock the session + self.mock_session = MagicMock() + mock_session.return_value = self.mock_session + + # Mock request preparation + self.mock_request = MagicMock() + self.mock_session.prepare_request.return_value = self.mock_request + + # Create server instance with mocked components + self.server = Server("http://test") + + def test_default_ssl_config(self): + """Test that by default, no custom SSL context is used""" + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_weak_dh_config(self, mock_create_context): + """Test that weak DH keys can be allowed when configured""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # Configure SSL with weak DH + self.server.configure_ssl(allow_weak_dh=True) + + # Verify SSL context was created and configured correctly + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + + # Verify context was added to http options + self.assertEqual(self.server.http_options["verify"], mock_context) + + @patch("ssl.create_default_context") + def test_disable_weak_dh_config(self, mock_create_context): + """Test that SSL config can be reset to defaults""" + # Setup mock SSL context + mock_context = MagicMock() + mock_create_context.return_value = mock_context + + # First enable weak DH + self.server.configure_ssl(allow_weak_dh=True) + self.assertIsNotNone(self.server._ssl_context) + self.assertIn("verify", self.server.http_options) + + # Then disable it + self.server.configure_ssl(allow_weak_dh=False) + self.assertIsNone(self.server._ssl_context) + self.assertNotIn("verify", self.server.http_options) + + @patch("ssl.create_default_context") + def test_warning_on_weak_dh(self, mock_create_context): + """Test that a warning is logged when enabling weak DH keys""" + logging.getLogger().setLevel(logging.WARNING) + with self.assertLogs(level="WARNING") as log: + self.server.configure_ssl(allow_weak_dh=True) + self.assertTrue( + any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), + "Expected warning about weak DH keys was not logged", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_user.py b/test/test_user.py index a46624845..fa2ac3a12 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,14 +1,16 @@ import os import unittest +from defusedxml import ElementTree as ET import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") @@ -162,6 +164,22 @@ def test_populate_workbooks(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) + def test_populate_owned_workbooks(self) -> None: + with open(POPULATE_WORKBOOKS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) + single_user = TSC.UserItem("test", "Interactor") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + def test_populate_workbooks_missing_id(self) -> None: single_user = TSC.UserItem("test", "Interactor") self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) @@ -233,3 +251,72 @@ def test_get_users_from_file(self): users, failures = self.server.users.create_from_file(USERS) assert users[0].name == "Cassie", users assert failures == [] + + def test_get_users_all_fields(self) -> None: + self.server.version = "3.7" + baseurl = self.server.users.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = self.server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + + def test_add_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(self.server.users.baseurl, text=response_xml) + user = self.server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + def test_update_user_idp_configuration(self) -> None: + with open(ADD_XML) as f: + response_xml = f.read() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) + user = self.server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" diff --git a/test/test_view.py b/test/test_view.py index 3fdaf60e6..ee6d518de 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -5,13 +5,14 @@ import tableauserverclient as TSC from tableauserverclient import UserItem, GroupItem, PermissionsRule -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") @@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None: req_option = TSC.PDFRequestOptions(viz_width=1920) with self.assertRaises(ValueError): req_option.get_query_params() + + def test_view_get_all_fields(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.views.baseurl + with open(GET_XML_ALL_FIELDS) as f: + response_xml = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}?fields=_all_", text=response_xml) + views, _ = self.server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_workbook.py b/test/test_workbook.py index f3c2dd147..84afd7fcb 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -10,7 +10,7 @@ import pytest import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory @@ -24,6 +24,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml") ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") @@ -978,3 +979,106 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + + def test_get_workbook_all_fields(self) -> None: + self.server.version = "3.21" + baseurl = self.server.workbooks.baseurl + + with open(GET_XML_ALL_FIELDS) as f: + response = f.read() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = self.server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator" From c728b19e115b8cf7aac56dcbb8ee131d2d39b78e Mon Sep 17 00:00:00 2001 From: casey-crawford-cfa Date: Fri, 16 May 2025 08:15:44 -0500 Subject: [PATCH 566/567] Tableau API is expecting an integer or --- tableauserverclient/models/interval_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 14cec1878..52fd658c5 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -318,4 +318,4 @@ def interval(self, interval_values): self._interval = interval_values def _interval_type_pairs(self): - return [(IntervalItem.Occurrence.MonthDay, self.interval)] + return [(IntervalItem.Occurrence.MonthDay, str(day)) for day in self.interval] From b597886b58e3a2ce5bff18edfdfb8a3f37fe827c Mon Sep 17 00:00:00 2001 From: sparklingSky Date: Fri, 1 Aug 2025 09:48:40 +0300 Subject: [PATCH 567/567] Update permissions_item.py --added ExtractRefresh attribute (#1617) --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bb3487279..b91cf89ca 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -43,6 +43,7 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" PulseMetricDefine = "PulseMetricDefine" + ExtractRefresh = "ExtractRefresh" def __repr__(self): return ""